認証まわりのコードを色々変えた
This commit is contained in:
parent
28aacd3a7f
commit
9d712e9cc7
|
@ -21,5 +21,7 @@ dependencies {
|
|||
implementation fileTree(dir: "libs", include: ["*.jar"])
|
||||
//noinspection DifferentStdlibGradleVersion
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
|
||||
testImplementation "junit:junit:$junit_version"
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version"
|
||||
}
|
||||
|
|
|
@ -45,4 +45,5 @@ dependencies {
|
|||
implementation project(":base")
|
||||
|
||||
implementation "com.github.zjupure:webpdecoder:2.3.$glideVersion"
|
||||
testImplementation 'junit:junit:4.12'
|
||||
}
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
package jp.juggler.apng;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.InstrumentationRegistry;
|
||||
import androidx.test.runner.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
@Test
|
||||
public void useAppContext() throws Exception{
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getTargetContext();
|
||||
|
||||
assertEquals( "jp.juggler.apng.test", appContext.getPackageName() );
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
package jp.juggler.apng;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception{
|
||||
assertEquals( 4, 2 + 2 );
|
||||
}
|
||||
}
|
|
@ -28,8 +28,9 @@ android {
|
|||
versionCode 509
|
||||
versionName "5.0.9"
|
||||
applicationId "jp.juggler.subwaytooter"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
viewBinding {
|
||||
|
@ -147,11 +148,6 @@ dependencies {
|
|||
testImplementation(project(":base"))
|
||||
androidTestImplementation(project(":base"))
|
||||
|
||||
testApi "androidx.arch.core:core-testing:$arch_version"
|
||||
testApi "junit:junit:$junit_version"
|
||||
testApi "org.jetbrains.kotlin:kotlin-test"
|
||||
testApi "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_version"
|
||||
|
||||
androidTestApi "androidx.test.espresso:espresso-core:3.5.1"
|
||||
androidTestApi "androidx.test.ext:junit-ktx:1.1.5"
|
||||
androidTestApi "androidx.test.ext:junit:1.1.5"
|
||||
|
@ -161,6 +157,10 @@ dependencies {
|
|||
androidTestApi "androidx.test:runner:1.5.2"
|
||||
androidTestApi "org.jetbrains.kotlin:kotlin-test"
|
||||
androidTestApi "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_version"
|
||||
testApi "androidx.arch.core:core-testing:$arch_version"
|
||||
testApi "junit:junit:$junit_version"
|
||||
testApi "org.jetbrains.kotlin:kotlin-test"
|
||||
testApi "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_version"
|
||||
|
||||
// To use android test orchestrator
|
||||
androidTestUtil "androidx.test:orchestrator:1.4.2"
|
||||
|
|
|
@ -73,7 +73,7 @@ class TestTootInstance {
|
|||
val (ti, ri) = TootInstance.getEx(client, hostArg = host)
|
||||
assertNull("no error", ri?.error)
|
||||
assertNotNull("instance information", ti)
|
||||
ti!!.run { log.d("$instanceType $uri $version") }
|
||||
ti?.run { log.d("$instanceType $apDomain $version") }
|
||||
}
|
||||
a(Host.parse("mastodon.juggler.jp"))
|
||||
a(Host.parse("misskey.io"))
|
||||
|
@ -85,7 +85,7 @@ class TestTootInstance {
|
|||
val (ti, ri) = TootInstance.getEx(client, account = account)
|
||||
assertNull(ri?.error)
|
||||
assertNotNull(ti)
|
||||
ti!!.run { log.d("${account.acct} $instanceType $uri $version") }
|
||||
ti?.run { log.d("${account.acct} $instanceType $apDomain $version") }
|
||||
}
|
||||
a(SavedAccount(45, "tateisu@mastodon.juggler.jp"))
|
||||
a(SavedAccount(45, "tateisu@misskey.io", misskeyVersion = 12))
|
||||
|
|
|
@ -2,16 +2,19 @@
|
|||
|
||||
package jp.juggler.subwaytooter.api
|
||||
|
||||
import androidx.core.net.toUri
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import jp.juggler.subwaytooter.api.auth.AuthBase
|
||||
import jp.juggler.subwaytooter.api.auth.MastodonAuth
|
||||
import jp.juggler.subwaytooter.api.entity.Host
|
||||
import jp.juggler.subwaytooter.api.entity.TootInstance
|
||||
import jp.juggler.subwaytooter.table.ClientInfo
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.subwaytooter.testutil.MainDispatcherRule
|
||||
import jp.juggler.subwaytooter.testutil.assertThrowsSuspend
|
||||
import jp.juggler.subwaytooter.util.SimpleHttpClient
|
||||
import jp.juggler.util.data.JsonObject
|
||||
import jp.juggler.util.data.buildJsonArray
|
||||
import jp.juggler.util.data.buildJsonObject
|
||||
import jp.juggler.util.data.*
|
||||
import jp.juggler.util.log.LogCategory
|
||||
import jp.juggler.util.network.MEDIA_TYPE_JSON
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
@ -25,7 +28,6 @@ import org.junit.Assert.*
|
|||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
@Suppress("MemberVisibilityCanPrivate")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
|
@ -38,6 +40,8 @@ class TestTootApiClient {
|
|||
|
||||
companion object {
|
||||
private val log = LogCategory("TestTootApiClient")
|
||||
private val mediaTypeTextPlain = "text/plain".toMediaType()
|
||||
private val mediaTypeHtml = "text/html".toMediaType()
|
||||
}
|
||||
|
||||
private val appContext = InstrumentationRegistry.getInstrumentation().targetContext!!
|
||||
|
@ -53,7 +57,7 @@ class TestTootApiClient {
|
|||
|
||||
override suspend fun getResponse(
|
||||
request: Request,
|
||||
tmpOkhttpClient: OkHttpClient?,
|
||||
overrideClient: OkHttpClient?,
|
||||
): Response {
|
||||
return responseGenerator(request)
|
||||
}
|
||||
|
@ -104,9 +108,9 @@ class TestTootApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
private fun createHttpClientNormal(): SimpleHttpClient {
|
||||
private fun createHttpClientMock(): SimpleHttpClient {
|
||||
return SimpleHttpClientMock(
|
||||
responseGenerator = { request: Request ->
|
||||
responseGenerator = { request ->
|
||||
|
||||
val bodyString = requestBodyString(request)
|
||||
|
||||
|
@ -258,15 +262,10 @@ class TestTootApiClient {
|
|||
webSocketGenerator = { request: Request, _: WebSocketListener ->
|
||||
object : WebSocket {
|
||||
override fun queueSize(): Long = 4096L
|
||||
|
||||
override fun send(text: String): Boolean = true
|
||||
|
||||
override fun send(bytes: ByteString): Boolean = true
|
||||
|
||||
override fun close(code: Int, reason: String?): Boolean = true
|
||||
|
||||
override fun cancel() = Unit
|
||||
|
||||
override fun request(): Request = request
|
||||
}
|
||||
}
|
||||
|
@ -382,9 +381,6 @@ class TestTootApiClient {
|
|||
.body(strJsonObject2.toResponseBody(MEDIA_TYPE_JSON))
|
||||
.build()
|
||||
|
||||
private val mediaTypeTextPlain = "text/plain".toMediaType()
|
||||
private val mediaTypeHtml = "text/html".toMediaType()
|
||||
|
||||
private fun createResponsePlainText() = Response.Builder()
|
||||
.request(requestSimple)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
|
@ -582,7 +578,7 @@ class TestTootApiClient {
|
|||
run {
|
||||
val client = TootApiClient(
|
||||
appContext,
|
||||
httpClient = createHttpClientNormal(),
|
||||
httpClient = createHttpClientMock(),
|
||||
callback = callback
|
||||
)
|
||||
val result = TootApiResult.makeWithCaption("instance")
|
||||
|
@ -630,7 +626,7 @@ class TestTootApiClient {
|
|||
run {
|
||||
val client = TootApiClient(
|
||||
appContext,
|
||||
httpClient = createHttpClientNormal(),
|
||||
httpClient = createHttpClientMock(),
|
||||
callback = callback
|
||||
)
|
||||
val result = TootApiResult.makeWithCaption("instance")
|
||||
|
@ -656,7 +652,7 @@ class TestTootApiClient {
|
|||
val callback = ProgressRecordTootApiCallback()
|
||||
val client = TootApiClient(
|
||||
appContext,
|
||||
httpClient = createHttpClientNormal(),
|
||||
httpClient = createHttpClientMock(),
|
||||
callback = callback
|
||||
)
|
||||
|
||||
|
@ -765,7 +761,7 @@ class TestTootApiClient {
|
|||
val callback = ProgressRecordTootApiCallback()
|
||||
val client = TootApiClient(
|
||||
appContext,
|
||||
httpClient = createHttpClientNormal(),
|
||||
httpClient = createHttpClientMock(),
|
||||
callback = callback
|
||||
)
|
||||
|
||||
|
@ -884,7 +880,7 @@ class TestTootApiClient {
|
|||
val callback = ProgressRecordTootApiCallback()
|
||||
val client = TootApiClient(
|
||||
appContext,
|
||||
httpClient = createHttpClientNormal(),
|
||||
httpClient = createHttpClientMock(),
|
||||
callback = callback
|
||||
)
|
||||
|
||||
|
@ -1060,91 +1056,145 @@ class TestTootApiClient {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun testRegisterClient() {
|
||||
runTest {
|
||||
val callback = ProgressRecordTootApiCallback()
|
||||
val client = TootApiClient(
|
||||
appContext,
|
||||
httpClient = createHttpClientNormal(),
|
||||
callback = callback
|
||||
)
|
||||
val instance = Host.parse("unit-test")
|
||||
client.apiHost = instance
|
||||
val clientName = "SubwayTooterUnitTest"
|
||||
val scope_string = "read+write+follow+push"
|
||||
fun testRegisterClient() = runTest {
|
||||
AuthBase.testClientName = "SubwayTooterUnitTest"
|
||||
val client = TootApiClient(
|
||||
appContext,
|
||||
httpClient = createHttpClientMock(),
|
||||
callback = ProgressRecordTootApiCallback()
|
||||
)
|
||||
val testHost = Host.parse("unit-test")
|
||||
client.apiHost = testHost
|
||||
|
||||
// まずクライアント情報を作らないとcredentialのテストができない
|
||||
var result = client.registerClient(scope_string, clientName)
|
||||
assertNotNull(result)
|
||||
assertEquals(null, result?.error)
|
||||
var jsonObject = result?.jsonObject
|
||||
assertNotNull(jsonObject)
|
||||
if (jsonObject == null) return@runTest
|
||||
val clientInfo = jsonObject
|
||||
val (ti, ri) = TootInstance.get(client)
|
||||
ti ?: error("can't get server information. ${ri?.error}")
|
||||
|
||||
// clientCredential の作成
|
||||
result = client.getClientCredential(clientInfo)
|
||||
assertNotNull(result)
|
||||
assertEquals(null, result?.error)
|
||||
val clientCredential = result?.string
|
||||
assertNotNull(clientCredential)
|
||||
if (clientCredential == null) return@runTest
|
||||
clientInfo[TootApiClient.KEY_CLIENT_CREDENTIAL] = clientCredential
|
||||
val auth = AuthBase.findAuth(client, ti, ri) as MastodonAuth
|
||||
val authUri = auth.authStep1(ti, forceUpdateClient = false)
|
||||
println("authUri=$authUri")
|
||||
|
||||
// clientCredential の検証
|
||||
result = client.verifyClientCredential(clientCredential)
|
||||
assertNotNull(result)
|
||||
assertEquals(null, result?.error)
|
||||
jsonObject = result?.jsonObject
|
||||
assertNotNull(jsonObject) // 中味は別に見てない。jsonObjectなら良いらしい
|
||||
if (jsonObject == null) return@runTest
|
||||
// ブラウザからコールバックで受け取ったcodeを処理する
|
||||
|
||||
var url: String?
|
||||
val clientInfo = jsonObjectOf(
|
||||
// ...
|
||||
"client_id" to "abc",
|
||||
"client_secret" to "def",
|
||||
AuthBase.KEY_CLIENT_SCOPE to "scope",
|
||||
)
|
||||
|
||||
// ブラウザURLの作成
|
||||
url = client.prepareBrowserUrl(scope_string, clientInfo)
|
||||
assertNotNull(url)
|
||||
println(url)
|
||||
ClientInfo.save(testHost, AuthBase.clientName, clientInfo.toString())
|
||||
|
||||
// ここまでと同じことをauthorize1でまとめて行う
|
||||
result = client.authentication1(clientName)
|
||||
url = result?.string
|
||||
assertNotNull(url)
|
||||
if (url == null) return@runTest
|
||||
println(url)
|
||||
|
||||
// ブラウザからコールバックで受け取ったcodeを処理する
|
||||
val refToken = AtomicReference<String>(null)
|
||||
result = client.authentication2Mastodon(clientName, "DUMMY_CODE", refToken)
|
||||
jsonObject = result?.jsonObject
|
||||
assertNotNull(jsonObject)
|
||||
if (jsonObject == null) return@runTest
|
||||
println(jsonObject.toString())
|
||||
|
||||
// 認証できたならアクセストークンがある
|
||||
val tokenInfo = result?.tokenInfo
|
||||
assertNotNull(tokenInfo)
|
||||
if (tokenInfo == null) return@runTest
|
||||
val accessToken = tokenInfo.string("access_token")
|
||||
assertNotNull(accessToken)
|
||||
if (accessToken == null) return@runTest
|
||||
|
||||
// アカウント手動入力でログインする場合はこの関数を直接呼び出す
|
||||
result = client.getUserCredential(accessToken, tokenInfo)
|
||||
jsonObject = result?.jsonObject
|
||||
assertNotNull(jsonObject)
|
||||
if (jsonObject == null) return@runTest
|
||||
println(jsonObject.toString())
|
||||
// コールバックのエラーケース
|
||||
arrayOf(
|
||||
// handle error message
|
||||
Triple("?error=e1", IllegalStateException::class.java, "e1"),
|
||||
Triple("?error_description=e1", IllegalStateException::class.java, "e1"),
|
||||
Triple("?error=e1&error_description=e2", IllegalStateException::class.java, "e1 e2"),
|
||||
// missing 'code'
|
||||
Triple("", IllegalStateException::class.java, "missing code in callback url."),
|
||||
Triple("?", IllegalStateException::class.java, "missing code in callback url."),
|
||||
Triple("?code=", IllegalStateException::class.java, "missing code in callback url."),
|
||||
// missing 'state'
|
||||
Triple("?code=a", IllegalStateException::class.java, "missing state in callback url."),
|
||||
Triple(
|
||||
"?code=a&state=",
|
||||
IllegalStateException::class.java,
|
||||
"missing state in callback url."
|
||||
),
|
||||
// bad db id
|
||||
Triple(
|
||||
"?code=a&state=db:",
|
||||
IllegalStateException::class.java,
|
||||
"invalide state.db in callback parameter."
|
||||
),
|
||||
Triple(
|
||||
"?code=a&state=db:a",
|
||||
IllegalStateException::class.java,
|
||||
"invalide state.db in callback parameter."
|
||||
),
|
||||
Triple(
|
||||
"?code=a&state=db:-1",
|
||||
IllegalStateException::class.java,
|
||||
"invalide state.db in callback parameter."
|
||||
),
|
||||
// bad host
|
||||
Triple(
|
||||
"?code=a&state=host:",
|
||||
IllegalStateException::class.java,
|
||||
"can't find client info for apiHost=, clientName=SubwayTooterUnitTest"
|
||||
),
|
||||
Triple(
|
||||
"?code=a&state=host:a",
|
||||
IllegalStateException::class.java,
|
||||
"can't find client info for apiHost=a, clientName=SubwayTooterUnitTest"
|
||||
),
|
||||
Triple(
|
||||
"?code=a&state=host:-1",
|
||||
IllegalStateException::class.java,
|
||||
"can't find client info for apiHost=-1, clientName=SubwayTooterUnitTest"
|
||||
),
|
||||
// other params in state ignored
|
||||
Triple("?code=a&state=host:${testHost.ascii},other:a", null, "ignored"),
|
||||
).forEach {
|
||||
val (suffix, exClass, exMessage) = it
|
||||
val callbackUrl = "${MastodonAuth.callbackUrl}$suffix".toUri()
|
||||
if (exClass == null) {
|
||||
// expect not throw
|
||||
auth.authStep2(callbackUrl)
|
||||
} else {
|
||||
val ex = assertThrowsSuspend("exClass callbackUrl=$callbackUrl", exClass) {
|
||||
auth.authStep2(callbackUrl)
|
||||
}
|
||||
assertEquals("exMessage callbackUrl=$callbackUrl", exMessage, ex.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 正常ケース
|
||||
val auth2Result =
|
||||
auth.authStep2("${MastodonAuth.callbackUrl}?code=a&state=host:${testHost.ascii}".toUri())
|
||||
|
||||
assertEquals(
|
||||
"auth2Result.tokenJson",
|
||||
"""{"SubwayTooterAuthVersion":5,"access_token":"DUMMY_ACCESS_TOKEN"}""",
|
||||
auth2Result.tokenJson.toString(0, sort = true)
|
||||
)
|
||||
assertEquals(
|
||||
"auth2Result.accountJson",
|
||||
"""{"_fromStream":false,"acct":"user1","id":1,"url":"http://unit-test/@user1","username":"user1"}""",
|
||||
auth2Result.accountJson.toString(0, sort = true)
|
||||
)
|
||||
|
||||
// 認証できたならアクセストークンがある
|
||||
val accessToken = auth2Result.tokenJson.string("access_token")
|
||||
assertEquals(
|
||||
"accessToken",
|
||||
"DUMMY_ACCESS_TOKEN",
|
||||
accessToken
|
||||
)
|
||||
accessToken!!
|
||||
|
||||
// アクセストークン手動入力
|
||||
val outTokenJson = JsonObject()
|
||||
val accountJson = auth.verifyAccount(accessToken, outTokenJson, misskeyVersion = 0)
|
||||
assertEquals(
|
||||
"outTokenJson",
|
||||
"""{"SubwayTooterAuthVersion":5,"access_token":"DUMMY_ACCESS_TOKEN"}""",
|
||||
outTokenJson.toString(0, sort = true)
|
||||
)
|
||||
assertEquals(
|
||||
"accountJson",
|
||||
"""{"acct":"user1","id":1,"url":"http://unit-test/@user1","username":"user1"}""",
|
||||
accountJson.toString(0, sort = true)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetInstanceInformation() {
|
||||
fun testGetInstanceInformation() =
|
||||
runTest {
|
||||
val callback = ProgressRecordTootApiCallback()
|
||||
val client = TootApiClient(
|
||||
appContext,
|
||||
httpClient = createHttpClientNormal(),
|
||||
httpClient = createHttpClientMock(),
|
||||
callback = callback
|
||||
)
|
||||
val instance = Host.parse("unit-test")
|
||||
|
@ -1155,77 +1205,71 @@ class TestTootApiClient {
|
|||
val json = instanceResult?.jsonObject
|
||||
if (json != null) println(json.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetHttp() = runTest {
|
||||
val callback = ProgressRecordTootApiCallback()
|
||||
val client = TootApiClient(
|
||||
appContext,
|
||||
httpClient = createHttpClientMock(),
|
||||
callback = callback
|
||||
)
|
||||
val result = client.getHttp("http://juggler.jp/")
|
||||
val content = result?.string
|
||||
assertNotNull(content)
|
||||
println(content.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetHttp() {
|
||||
runTest {
|
||||
val callback = ProgressRecordTootApiCallback()
|
||||
val client = TootApiClient(
|
||||
appContext,
|
||||
httpClient = createHttpClientNormal(),
|
||||
callback = callback
|
||||
)
|
||||
val result = client.getHttp("http://juggler.jp/")
|
||||
val content = result?.string
|
||||
assertNotNull(content)
|
||||
println(content.toString())
|
||||
}
|
||||
fun testRequest() = runTest {
|
||||
val tokenInfo = JsonObject()
|
||||
tokenInfo["access_token"] = "DUMMY_ACCESS_TOKEN"
|
||||
|
||||
val accessInfo = SavedAccount(
|
||||
db_id = 1,
|
||||
acctArg = "user1@host1",
|
||||
apiHostArg = null,
|
||||
token_info = tokenInfo
|
||||
)
|
||||
val callback = ProgressRecordTootApiCallback()
|
||||
val client = TootApiClient(
|
||||
appContext,
|
||||
httpClient = createHttpClientMock(),
|
||||
callback = callback
|
||||
)
|
||||
client.account = accessInfo
|
||||
val result = client.request("/api/v1/timelines/public")
|
||||
println(result?.bodyString)
|
||||
|
||||
val content = result?.jsonArray
|
||||
assertNotNull(content)
|
||||
println(content?.jsonObject(0).toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRequest() {
|
||||
runTest {
|
||||
val tokenInfo = JsonObject()
|
||||
tokenInfo["access_token"] = "DUMMY_ACCESS_TOKEN"
|
||||
|
||||
val accessInfo = SavedAccount(
|
||||
db_id = 1,
|
||||
acctArg = "user1@host1",
|
||||
apiHostArg = null,
|
||||
token_info = tokenInfo
|
||||
)
|
||||
val callback = ProgressRecordTootApiCallback()
|
||||
val client = TootApiClient(
|
||||
appContext,
|
||||
httpClient = createHttpClientNormal(),
|
||||
callback = callback
|
||||
)
|
||||
client.account = accessInfo
|
||||
val result = client.request("/api/v1/timelines/public")
|
||||
println(result?.bodyString)
|
||||
|
||||
val content = result?.jsonArray
|
||||
assertNotNull(content)
|
||||
println(content?.jsonObject(0).toString())
|
||||
fun testWebSocket() = runTest {
|
||||
val tokenInfo = buildJsonObject {
|
||||
put("access_token", "DUMMY_ACCESS_TOKEN")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWebSocket() {
|
||||
runTest {
|
||||
val tokenInfo = buildJsonObject {
|
||||
put("access_token", "DUMMY_ACCESS_TOKEN")
|
||||
}
|
||||
|
||||
val accessInfo = SavedAccount(
|
||||
db_id = 1,
|
||||
acctArg = "user1@host1",
|
||||
apiHostArg = null,
|
||||
token_info = tokenInfo
|
||||
)
|
||||
val callback = ProgressRecordTootApiCallback()
|
||||
val client = TootApiClient(
|
||||
appContext,
|
||||
httpClient = createHttpClientNormal(),
|
||||
callback = callback
|
||||
)
|
||||
client.account = accessInfo
|
||||
val (_, ws) = client.webSocket("/api/v1/streaming/?stream=public:local",
|
||||
object : WebSocketListener() {
|
||||
})
|
||||
assertNotNull(ws)
|
||||
ws?.cancel()
|
||||
}
|
||||
val accessInfo = SavedAccount(
|
||||
db_id = 1,
|
||||
acctArg = "user1@host1",
|
||||
apiHostArg = null,
|
||||
token_info = tokenInfo
|
||||
)
|
||||
val callback = ProgressRecordTootApiCallback()
|
||||
val client = TootApiClient(
|
||||
appContext,
|
||||
httpClient = createHttpClientMock(),
|
||||
callback = callback
|
||||
)
|
||||
client.account = accessInfo
|
||||
val (_, ws) = client.webSocket(
|
||||
"/api/v1/streaming/?stream=public:local",
|
||||
object : WebSocketListener() {}
|
||||
)
|
||||
assertNotNull(ws)
|
||||
ws?.cancel()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
package jp.juggler.subwaytooter.testutil
|
||||
|
||||
private fun formatClass(value: Class<*>) =
|
||||
value.canonicalName ?: value.name
|
||||
|
||||
private fun buildPrefix(message: String?) =
|
||||
if (message.isNullOrEmpty()) "" else "$message: "
|
||||
|
||||
private fun isEquals(expected: Any, actual: Any) =
|
||||
expected == actual
|
||||
|
||||
private fun equalsRegardingNull(expected: Any?, actual: Any?) =
|
||||
expected == actual
|
||||
|
||||
private fun formatClassAndValue(value: Any?, valueString: String) =
|
||||
"${value?.javaClass?.name ?: "null"}<$valueString>"
|
||||
|
||||
fun format(message: String?, expected: Any, actual: Any): String {
|
||||
val formatted = when {
|
||||
message.isNullOrEmpty() -> ""
|
||||
else -> "$message "
|
||||
}
|
||||
val expectedString = expected.toString()
|
||||
val actualString = actual.toString()
|
||||
return when {
|
||||
expectedString != actualString ->
|
||||
("${formatted}expected:<$expectedString> but was:<$actualString>")
|
||||
else ->
|
||||
("${formatted}expected: ${
|
||||
formatClassAndValue(expected, expectedString)
|
||||
} but was: ${
|
||||
formatClassAndValue(actual, actualString)
|
||||
}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <T : Throwable?> assertThrowsSuspend(
|
||||
message: String?,
|
||||
expectedThrowable: Class<T>,
|
||||
runnable: suspend () -> Unit,
|
||||
): T {
|
||||
try {
|
||||
runnable()
|
||||
} catch (actualThrown: Throwable) {
|
||||
return if (expectedThrowable.isInstance(actualThrown)) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
actualThrown as T
|
||||
} else {
|
||||
var expected = formatClass(expectedThrowable)
|
||||
val actualThrowable: Class<out Throwable> = actualThrown.javaClass
|
||||
var actual = formatClass(actualThrowable)
|
||||
if (expected == actual) {
|
||||
// There must be multiple class loaders. Add the identity hash code so the message
|
||||
// doesn't say "expected: java.lang.String<my.package.MyException> ..."
|
||||
expected += "@" + Integer.toHexString(System.identityHashCode(expectedThrowable))
|
||||
actual += "@" + Integer.toHexString(System.identityHashCode(actualThrowable))
|
||||
}
|
||||
val mismatchMessage = (buildPrefix(message)
|
||||
+ format("unexpected exception type thrown;", expected, actual))
|
||||
|
||||
// The AssertionError(String, Throwable) ctor is only available on JDK7.
|
||||
val assertionError = AssertionError(mismatchMessage)
|
||||
assertionError.initCause(actualThrown)
|
||||
throw assertionError
|
||||
}
|
||||
}
|
||||
val notThrownMessage = "${
|
||||
buildPrefix(message)
|
||||
}expected ${
|
||||
formatClass(expectedThrowable)
|
||||
} to be thrown, but nothing was thrown"
|
||||
throw AssertionError(notThrownMessage)
|
||||
}
|
|
@ -16,20 +16,18 @@ import android.widget.*
|
|||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import jp.juggler.subwaytooter.action.accountRemove
|
||||
import jp.juggler.subwaytooter.api.TootApiClient
|
||||
import jp.juggler.subwaytooter.api.TootApiResult
|
||||
import jp.juggler.subwaytooter.api.TootParser
|
||||
import jp.juggler.subwaytooter.api.*
|
||||
import jp.juggler.subwaytooter.api.auth.AuthBase
|
||||
import jp.juggler.subwaytooter.api.entity.*
|
||||
import jp.juggler.subwaytooter.api.runApiTask
|
||||
import jp.juggler.subwaytooter.databinding.ActAccountSettingBinding
|
||||
import jp.juggler.subwaytooter.dialog.ActionsDialog
|
||||
import jp.juggler.subwaytooter.notification.*
|
||||
import jp.juggler.subwaytooter.pref.PrefB
|
||||
import jp.juggler.subwaytooter.pref.PrefS
|
||||
import jp.juggler.subwaytooter.table.AcctColor
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.subwaytooter.util.*
|
||||
import jp.juggler.util.*
|
||||
import jp.juggler.util.coroutine.AppDispatchers
|
||||
import jp.juggler.util.coroutine.launchMain
|
||||
import jp.juggler.util.coroutine.launchProgress
|
||||
import jp.juggler.util.data.*
|
||||
|
@ -42,6 +40,7 @@ import jp.juggler.util.network.toPatch
|
|||
import jp.juggler.util.network.toPost
|
||||
import jp.juggler.util.network.toPostRequestBuilder
|
||||
import jp.juggler.util.ui.*
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import okhttp3.MediaType
|
||||
|
@ -121,7 +120,7 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
|
||||
lateinit var account: SavedAccount
|
||||
|
||||
private val viewBinding by lazy {
|
||||
private val views by lazy {
|
||||
ActAccountSettingBinding.inflate(layoutInflater, null, false)
|
||||
}
|
||||
|
||||
|
@ -231,7 +230,7 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
|
||||
initializeProfile()
|
||||
|
||||
viewBinding.btnOpenBrowser.text =
|
||||
views.btnOpenBrowser.text =
|
||||
getString(R.string.open_instance_website, account.apiHost.pretty)
|
||||
}
|
||||
|
||||
|
@ -251,12 +250,12 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
private fun initUI() {
|
||||
this.density = resources.displayMetrics.density
|
||||
this.handler = App1.getAppState(this).handler
|
||||
setContentView(viewBinding.root)
|
||||
setSupportActionBar(viewBinding.toolbar)
|
||||
fixHorizontalPadding(viewBinding.svContent)
|
||||
setSwitchColor(viewBinding.root)
|
||||
setContentView(views.root)
|
||||
setSupportActionBar(views.toolbar)
|
||||
fixHorizontalPadding(views.svContent)
|
||||
setSwitchColor(views.root)
|
||||
|
||||
viewBinding.apply {
|
||||
views.apply {
|
||||
btnPushSubscriptionNotForce.vg(BuildConfig.DEBUG)
|
||||
|
||||
imageResizeItems = SavedAccount.resizeConfigList.map {
|
||||
|
@ -337,7 +336,7 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
R.id.etFieldValue4
|
||||
).map { findViewById(it) }
|
||||
|
||||
btnNotificationStyleEditReply.vg(PrefB.bpSeparateReplyNotificationGroup())
|
||||
btnNotificationStyleEditReply.vg(PrefB.bpSeparateReplyNotificationGroup.invoke())
|
||||
|
||||
nameInvalidator = NetworkEmojiInvalidator(handler, etDisplayName)
|
||||
noteInvalidator = NetworkEmojiInvalidator(handler, etNote)
|
||||
|
@ -355,7 +354,7 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
saveUIToData()
|
||||
}
|
||||
|
||||
viewBinding.root.scan {
|
||||
views.root.scan {
|
||||
when (it) {
|
||||
etMaxTootChars -> etMaxTootChars.addTextChangedListener(
|
||||
simpleTextWatcher {
|
||||
|
@ -390,7 +389,7 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
loadingBusy = true
|
||||
try {
|
||||
|
||||
viewBinding.apply {
|
||||
views.apply {
|
||||
|
||||
tvInstance.text = a.apiHost.pretty
|
||||
tvUser.text = a.acct.pretty
|
||||
|
@ -507,7 +506,7 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
private fun showAcctColor() {
|
||||
val sa = this.account
|
||||
val ac = AcctColor.load(sa)
|
||||
viewBinding.tvUserCustom.apply {
|
||||
views.tvUserCustom.apply {
|
||||
backgroundColor = ac.color_bg
|
||||
text = ac.nickname
|
||||
textColor = ac.color_fg.notZero() ?: attrColor(R.attr.colorTimeSmall)
|
||||
|
@ -519,7 +518,7 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
if (loadingBusy) return
|
||||
account.visibility = visibility
|
||||
|
||||
viewBinding.apply {
|
||||
views.apply {
|
||||
|
||||
account.dont_hide_nsfw = swNSFWOpen.isChecked
|
||||
account.dont_show_timeout = swDontShowTimeout.isChecked
|
||||
|
@ -574,7 +573,7 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
}
|
||||
|
||||
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
|
||||
if (buttonView == viewBinding.cbLocked) {
|
||||
if (buttonView == views.cbLocked) {
|
||||
if (!profileBusy) sendLocked(isChecked)
|
||||
} else {
|
||||
saveUIToData()
|
||||
|
@ -634,7 +633,7 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
}
|
||||
|
||||
private fun showVisibility() {
|
||||
viewBinding.btnVisibility.text =
|
||||
views.btnVisibility.text =
|
||||
getVisibilityString(this, account.isMisskey, visibility)
|
||||
}
|
||||
|
||||
|
@ -705,19 +704,19 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
val tmpDefaultSensitive = json.boolean("posting:default:sensitive")
|
||||
if (tmpDefaultSensitive != null) {
|
||||
bChanged = true
|
||||
viewBinding.swMarkSensitive.isChecked = tmpDefaultSensitive
|
||||
views.swMarkSensitive.isChecked = tmpDefaultSensitive
|
||||
}
|
||||
|
||||
val tmpExpandMedia = json.string("reading:expand:media")
|
||||
if (tmpExpandMedia?.isNotEmpty() == true) {
|
||||
bChanged = true
|
||||
viewBinding.swNSFWOpen.isChecked = (tmpExpandMedia == "show_all")
|
||||
views.swNSFWOpen.isChecked = (tmpExpandMedia == "show_all")
|
||||
}
|
||||
|
||||
val tmpExpandCW = json.boolean("reading:expand:spoilers")
|
||||
if (tmpExpandCW != null) {
|
||||
bChanged = true
|
||||
viewBinding.swExpandCW.isChecked = tmpExpandCW
|
||||
views.swExpandCW.isChecked = tmpExpandCW
|
||||
}
|
||||
} finally {
|
||||
loadingBusy = false
|
||||
|
@ -744,27 +743,17 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
///////////////////////////////////////////////////
|
||||
private fun performAccessToken() {
|
||||
launchMain {
|
||||
runApiTask(account) { client ->
|
||||
client.authentication1(
|
||||
PrefS.spClientName(this@ActAccountSetting),
|
||||
forceUpdateClient = true
|
||||
)
|
||||
}?.let { result ->
|
||||
val uri = result.string.mayUri()
|
||||
val error = result.error
|
||||
when {
|
||||
uri != null -> {
|
||||
val data = Intent()
|
||||
data.data = uri
|
||||
setResult(Activity.RESULT_OK, data)
|
||||
try {
|
||||
runApiTask2(account) { client ->
|
||||
val authUrl = client.authStep1(forceUpdateClient = true)
|
||||
withContext(AppDispatchers.MainImmediate) {
|
||||
val resultIntent = Intent().apply { data = authUrl }
|
||||
setResult(Activity.RESULT_OK, resultIntent)
|
||||
finish()
|
||||
}
|
||||
|
||||
error != null -> {
|
||||
showToast(true, error)
|
||||
log.e("can't get oauth browser URL. $error")
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
showApiError(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -787,7 +776,7 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
else -> "(loading…)"
|
||||
}
|
||||
|
||||
viewBinding.apply {
|
||||
views.apply {
|
||||
|
||||
ivProfileAvatar.setErrorImage(defaultColorIcon(this@ActAccountSetting, questionId))
|
||||
ivProfileAvatar.setDefaultImage(defaultColorIcon(this@ActAccountSetting, questionId))
|
||||
|
@ -823,38 +812,33 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
// サーバから情報をロードする
|
||||
private fun loadProfile() {
|
||||
launchMain {
|
||||
var resultAccount: TootAccount? = null
|
||||
runApiTask(account) { client ->
|
||||
if (account.isMisskey) {
|
||||
client.request(
|
||||
"/api/i",
|
||||
account.putMisskeyApiToken().toPostRequestBuilder()
|
||||
)?.also { result ->
|
||||
val jsonObject = result.jsonObject
|
||||
if (jsonObject != null) {
|
||||
resultAccount = TootParser(this, account).account(jsonObject)
|
||||
?: return@runApiTask TootApiResult("TootAccount parse failed.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val r0 = account.checkConfirmed(this, client)
|
||||
if (r0 == null || r0.error != null) return@runApiTask r0
|
||||
try {
|
||||
runApiTask2(account) { client ->
|
||||
val json = if (account.isMisskey) {
|
||||
val result = client.request(
|
||||
"/api/i",
|
||||
account.putMisskeyApiToken().toPostRequestBuilder()
|
||||
) ?: return@runApiTask2
|
||||
result.error?.let { error(it) }
|
||||
result.jsonObject
|
||||
} else {
|
||||
// 承認待ち状態のチェック
|
||||
account.checkConfirmed(this, client)
|
||||
|
||||
client.request("/api/v1/accounts/verify_credentials")
|
||||
?.also { result ->
|
||||
val jsonObject = result.jsonObject
|
||||
if (jsonObject != null) {
|
||||
resultAccount =
|
||||
TootParser(this@ActAccountSetting, account).account(jsonObject)
|
||||
?: return@runApiTask TootApiResult("TootAccount parse failed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}?.let { result ->
|
||||
when (val account = resultAccount) {
|
||||
null -> showToast(true, result.error)
|
||||
else -> showProfile(account)
|
||||
val result = client.request(
|
||||
"/api/v1/accounts/verify_credentials"
|
||||
) ?: return@runApiTask2
|
||||
result.error?.let { error(it) }
|
||||
result.jsonObject
|
||||
}
|
||||
val newAccount = TootParser(this, account)
|
||||
.account(json) ?: error("parse error.")
|
||||
withContext(AppDispatchers.MainImmediate) {
|
||||
showProfile(newAccount)
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
showApiError(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -865,13 +849,13 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
|
||||
profileBusy = true
|
||||
try {
|
||||
viewBinding.ivProfileAvatar.setImageUrl(
|
||||
calcIconRound(viewBinding.ivProfileAvatar.layoutParams),
|
||||
views.ivProfileAvatar.setImageUrl(
|
||||
calcIconRound(views.ivProfileAvatar.layoutParams),
|
||||
src.avatar_static,
|
||||
src.avatar
|
||||
)
|
||||
|
||||
viewBinding.ivProfileHeader.setImageUrl(
|
||||
views.ivProfileHeader.setImageUrl(
|
||||
0f,
|
||||
src.header_static,
|
||||
src.header
|
||||
|
@ -887,7 +871,7 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
|
||||
val displayName = src.display_name
|
||||
val name = decodeOptions.decodeEmoji(displayName)
|
||||
viewBinding.etDisplayName.setText(name)
|
||||
views.etDisplayName.setText(name)
|
||||
nameInvalidator.register(name)
|
||||
|
||||
val noteString = src.source?.note ?: src.note
|
||||
|
@ -901,13 +885,13 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
}
|
||||
}
|
||||
|
||||
viewBinding.etNote.setText(noteSpannable)
|
||||
views.etNote.setText(noteSpannable)
|
||||
noteInvalidator.register(noteSpannable)
|
||||
|
||||
viewBinding.cbLocked.isChecked = src.locked
|
||||
views.cbLocked.isChecked = src.locked
|
||||
|
||||
// 編集可能にする
|
||||
viewBinding.apply {
|
||||
views.apply {
|
||||
arrayOf(
|
||||
btnProfileAvatar,
|
||||
btnProfileHeader,
|
||||
|
@ -1012,7 +996,7 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
.setType(MultipartBody.FORM)
|
||||
|
||||
val apiKey =
|
||||
account.token_info?.string(TootApiClient.KEY_API_KEY_MISSKEY)
|
||||
account.token_info?.string(AuthBase.KEY_API_KEY_MISSKEY)
|
||||
if (apiKey?.isNotEmpty() == true) {
|
||||
multipartBuilder.addFormDataPart("i", apiKey)
|
||||
}
|
||||
|
@ -1167,7 +1151,7 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
val value = arg.second
|
||||
if (key == "locked" && value is Boolean) {
|
||||
profileBusy = true
|
||||
viewBinding.cbLocked.isChecked = !value
|
||||
views.cbLocked.isChecked = !value
|
||||
profileBusy = false
|
||||
}
|
||||
}
|
||||
|
@ -1177,7 +1161,7 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
}
|
||||
|
||||
private fun sendDisplayName(bConfirmed: Boolean = false) {
|
||||
val sv = viewBinding.etDisplayName.text.toString()
|
||||
val sv = views.etDisplayName.text.toString()
|
||||
if (!bConfirmed) {
|
||||
val length = sv.codePointCount(0, sv.length)
|
||||
if (length > max_length_display_name) {
|
||||
|
@ -1201,7 +1185,7 @@ class ActAccountSetting : AppCompatActivity(),
|
|||
}
|
||||
|
||||
private fun sendNote(bConfirmed: Boolean = false) {
|
||||
val sv = viewBinding.etNote.text.toString()
|
||||
val sv = views.etNote.text.toString()
|
||||
if (!bConfirmed) {
|
||||
|
||||
val length = TootAccount.countText(sv)
|
||||
|
|
|
@ -498,7 +498,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli
|
|||
views.etEditText.addTextChangedListener(this)
|
||||
|
||||
// https://stackoverflow.com/questions/13614101/fatal-crash-focus-search-returned-a-view-that-wasnt-able-to-take-focus
|
||||
views.etEditText.setOnEditorActionListener(OnEditorActionListener { textView, actionId, event ->
|
||||
views.etEditText.setOnEditorActionListener(OnEditorActionListener { textView, actionId, _ ->
|
||||
if (actionId == EditorInfo.IME_ACTION_NEXT) {
|
||||
@Suppress("WrongConstant")
|
||||
textView.focusSearch(FOCUS_FORWARD)?.requestFocus(FOCUS_FORWARD)
|
||||
|
|
|
@ -54,7 +54,7 @@ class ActDrawableList : AsyncActivity(), CoroutineScope {
|
|||
val reSkipName =
|
||||
"""^(abc_|avd_|btn_checkbox_|btn_radio_|googleg_|ic_keyboard_arrow_|ic_menu_arrow_|notification_|common_|emj_|cpv_|design_|exo_|mtrl_|ic_mtrl_)"""
|
||||
.asciiPattern()
|
||||
val list = withContext(AppDispatchers.io) {
|
||||
val list = withContext(AppDispatchers.IO) {
|
||||
R.drawable::class.java.fields
|
||||
.mapNotNull {
|
||||
val id = it.get(null) as? Int ?: return@mapNotNull null
|
||||
|
|
|
@ -22,18 +22,14 @@ import com.google.android.exoplayer2.source.MediaLoadData
|
|||
import com.google.android.exoplayer2.source.MediaSource
|
||||
import com.google.android.exoplayer2.source.MediaSourceEventListener
|
||||
import com.google.android.exoplayer2.util.RepeatModeUtil.REPEAT_TOGGLE_MODE_ONE
|
||||
import jp.juggler.subwaytooter.api.ApiTask
|
||||
import jp.juggler.subwaytooter.api.TootApiClient
|
||||
import jp.juggler.subwaytooter.api.TootApiResult
|
||||
import jp.juggler.subwaytooter.api.*
|
||||
import jp.juggler.subwaytooter.api.entity.*
|
||||
import jp.juggler.subwaytooter.api.runApiTask
|
||||
import jp.juggler.subwaytooter.databinding.ActMediaViewerBinding
|
||||
import jp.juggler.subwaytooter.dialog.ActionsDialog
|
||||
import jp.juggler.subwaytooter.drawable.MediaBackgroundDrawable
|
||||
import jp.juggler.subwaytooter.global.appPref
|
||||
import jp.juggler.subwaytooter.pref.PrefI
|
||||
import jp.juggler.subwaytooter.pref.put
|
||||
import jp.juggler.subwaytooter.util.ProgressResponseBody
|
||||
import jp.juggler.subwaytooter.util.permissionSpecMediaDownload
|
||||
import jp.juggler.subwaytooter.util.requester
|
||||
import jp.juggler.subwaytooter.view.PinchBitmapView
|
||||
|
@ -48,6 +44,7 @@ import jp.juggler.util.media.resolveOrientation
|
|||
import jp.juggler.util.media.rotateSize
|
||||
import jp.juggler.util.network.MySslSocketFactory
|
||||
import jp.juggler.util.ui.*
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.yield
|
||||
import okhttp3.Request
|
||||
import java.io.ByteArrayInputStream
|
||||
|
@ -566,51 +563,6 @@ class ActMediaViewer : AppCompatActivity(), View.OnClickListener {
|
|||
return Pair(bitmap2, null)
|
||||
}
|
||||
|
||||
private suspend fun getHttpCached(
|
||||
client: TootApiClient,
|
||||
url: String,
|
||||
): Pair<TootApiResult?, ByteArray?> {
|
||||
val result = TootApiResult.makeWithCaption(url)
|
||||
|
||||
val request = try {
|
||||
Request.Builder()
|
||||
.url(url)
|
||||
.cacheControl(App1.CACHE_CONTROL)
|
||||
.addHeader("Accept", "image/webp,image/*,*/*;q=0.8")
|
||||
.build()
|
||||
} catch (ex: Throwable) {
|
||||
result.setError(ex.withCaption("incorrect URL."))
|
||||
return Pair(result, null)
|
||||
}
|
||||
|
||||
if (!client.sendRequest(result, tmpOkhttpClient = App1.ok_http_client_media_viewer) {
|
||||
request
|
||||
}) return Pair(result, null)
|
||||
|
||||
if (client.isApiCancelled()) return Pair(null, null)
|
||||
|
||||
val response = result.response!!
|
||||
if (!response.isSuccessful) {
|
||||
result.parseErrorResponse()
|
||||
return Pair(result, null)
|
||||
}
|
||||
|
||||
try {
|
||||
val ba = ProgressResponseBody.bytes(response) { bytesRead, bytesTotal ->
|
||||
// 50MB以上のデータはキャンセルする
|
||||
if (max(bytesRead, bytesTotal) >= 50000000) {
|
||||
error("media attachment is larger than 50000000")
|
||||
}
|
||||
client.publishApiProgressRatio(bytesRead.toInt(), bytesTotal.toInt())
|
||||
}
|
||||
if (client.isApiCancelled()) return Pair(null, null)
|
||||
return Pair(result, ba)
|
||||
} catch (ignored: Throwable) {
|
||||
result.parseErrorResponse("?")
|
||||
return Pair(result, null)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private fun loadBitmap(ta: TootAttachment) {
|
||||
|
||||
|
@ -627,33 +579,52 @@ class ActMediaViewer : AppCompatActivity(), View.OnClickListener {
|
|||
}
|
||||
|
||||
launchMain {
|
||||
val options = BitmapFactory.Options()
|
||||
|
||||
var resultBitmap: Bitmap? = null
|
||||
|
||||
runApiTask(progressStyle = ApiTask.PROGRESS_HORIZONTAL) { client ->
|
||||
if (urlList.isEmpty()) return@runApiTask TootApiResult("missing url")
|
||||
var lastResult: TootApiResult? = null
|
||||
for (url in urlList) {
|
||||
val (result, ba) = getHttpCached(client, url)
|
||||
lastResult = result
|
||||
if (ba != null) {
|
||||
client.publishApiProgress("decoding image…")
|
||||
|
||||
val (bitmap, error) = decodeBitmap(options, ba, 2048)
|
||||
if (bitmap != null) {
|
||||
resultBitmap = bitmap
|
||||
break
|
||||
}
|
||||
if (error != null) lastResult = TootApiResult(error)
|
||||
try {
|
||||
val errors = ArrayList<String>()
|
||||
val bitmap = runApiTask2(progressStyle = ApiTask.PROGRESS_HORIZONTAL) { client ->
|
||||
if (urlList.isEmpty()) {
|
||||
errors.add("missing url(s)")
|
||||
}
|
||||
val options = BitmapFactory.Options()
|
||||
for (url in urlList) {
|
||||
try {
|
||||
val ba = Request.Builder()
|
||||
.url(url)
|
||||
.cacheControl(App1.CACHE_CONTROL)
|
||||
.addHeader("Accept", "image/webp,image/*,*/*;q=0.8")
|
||||
.build()
|
||||
.send(
|
||||
client,
|
||||
errorSuffix = url,
|
||||
overrideClient = App1.ok_http_client_media_viewer
|
||||
)
|
||||
.readBytes { bytesRead, bytesTotal ->
|
||||
// 50MB以上のデータはキャンセルする
|
||||
if (max(bytesRead, bytesTotal) >= 50000000) {
|
||||
error("media attachment is larger than 50000000")
|
||||
}
|
||||
client.publishApiProgressRatio(
|
||||
bytesRead.toInt(),
|
||||
bytesTotal.toInt()
|
||||
)
|
||||
}
|
||||
client.publishApiProgress("decoding image…")
|
||||
val (b, error) = decodeBitmap(options, ba, 2048)
|
||||
if (b != null) return@runApiTask2 b
|
||||
if (error != null) errors.add(error)
|
||||
} catch (ex: Throwable) {
|
||||
if (ex is CancellationException) break
|
||||
errors.add("load error. ${ex.withCaption()} url=$url")
|
||||
}
|
||||
}
|
||||
return@runApiTask2 null
|
||||
}
|
||||
lastResult
|
||||
}.let { result -> // may null
|
||||
when (val bitmap = resultBitmap) {
|
||||
null -> if (result != null) showToast(true, result.error)
|
||||
else -> views.pbvImage.setBitmap(bitmap)
|
||||
when {
|
||||
bitmap != null -> views.pbvImage.setBitmap(bitmap)
|
||||
else -> errors.notEmpty()?.let { dialogOrToast(it.joinToString("\n")) }
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
showApiError(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,10 +51,10 @@ internal suspend fun AppCompatActivity.addPseudoAccount(
|
|||
val rowId = SavedAccount.insert(
|
||||
acct = acct.ascii,
|
||||
host = host.ascii,
|
||||
domain = instanceInfo.uri,
|
||||
domain = instanceInfo.apDomain.ascii,
|
||||
account = accountInfo,
|
||||
token = JsonObject(),
|
||||
misskeyVersion = instanceInfo.misskeyVersion
|
||||
misskeyVersion = instanceInfo.misskeyVersionMajor
|
||||
)
|
||||
|
||||
account = SavedAccount.loadAccount(applicationContext, rowId)
|
||||
|
|
|
@ -3,13 +3,13 @@ package jp.juggler.subwaytooter.action
|
|||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.net.toUri
|
||||
import jp.juggler.subwaytooter.*
|
||||
import jp.juggler.subwaytooter.actmain.addColumn
|
||||
import jp.juggler.subwaytooter.actmain.afterAccountVerify
|
||||
import jp.juggler.subwaytooter.api.*
|
||||
import jp.juggler.subwaytooter.api.auth.Auth2Result
|
||||
import jp.juggler.subwaytooter.api.auth.MastodonAuth
|
||||
import jp.juggler.subwaytooter.api.entity.*
|
||||
import jp.juggler.subwaytooter.column.ColumnType
|
||||
import jp.juggler.subwaytooter.dialog.*
|
||||
|
@ -19,6 +19,7 @@ import jp.juggler.subwaytooter.table.SavedAccount
|
|||
import jp.juggler.subwaytooter.util.LinkHelper
|
||||
import jp.juggler.subwaytooter.util.openBrowser
|
||||
import jp.juggler.util.*
|
||||
import jp.juggler.util.coroutine.AppDispatchers
|
||||
import jp.juggler.util.coroutine.launchIO
|
||||
import jp.juggler.util.coroutine.launchMain
|
||||
import jp.juggler.util.data.JsonObject
|
||||
|
@ -66,66 +67,64 @@ private fun ActMain.accountCreate(
|
|||
) { dialog_create, username, email, password, agreement, reason ->
|
||||
// dialog引数が二つあるのに注意
|
||||
launchMain {
|
||||
var resultTootAccount: TootAccount? = null
|
||||
var resultApDomain: Host? = null
|
||||
runApiTask(apiHost) { client ->
|
||||
val r1 = client.createUser2Mastodon(
|
||||
clientInfo,
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
agreement,
|
||||
reason
|
||||
)
|
||||
val tokenJson = r1?.jsonObject ?: return@runApiTask r1
|
||||
try {
|
||||
val auth2Result = runApiTask2(apiHost) { client ->
|
||||
// Mastodon限定
|
||||
val misskeyVersion = 0 // TootInstance.parseMisskeyVersion(tokenJson)
|
||||
|
||||
val misskeyVersion = TootInstance.parseMisskeyVersion(tokenJson)
|
||||
val parser = TootParser(
|
||||
activity,
|
||||
linkHelper = LinkHelper.create(apiHost, misskeyVersion = misskeyVersion)
|
||||
)
|
||||
val auth = MastodonAuth(client)
|
||||
|
||||
// ここだけMastodon専用
|
||||
val accessToken = tokenJson.string("access_token")
|
||||
?: return@runApiTask TootApiResult("can't get user access token")
|
||||
|
||||
client.apiHost = apiHost
|
||||
val (ti, ri) = TootInstance.getEx(client, forceAccessToken = accessToken)
|
||||
ti ?: return@runApiTask ri
|
||||
|
||||
resultApDomain = ti.uri?.let { Host.parse(it) }
|
||||
|
||||
client.getUserCredential(accessToken, misskeyVersion = misskeyVersion)?.let { r2 ->
|
||||
parser.account(r2.jsonObject)?.let {
|
||||
resultTootAccount = it
|
||||
return@runApiTask r2
|
||||
}
|
||||
}
|
||||
|
||||
val jsonObject = buildJsonObject {
|
||||
put("id", EntityId.CONFIRMING.toString())
|
||||
put("username", username)
|
||||
put("acct", username)
|
||||
put("url", "https://$apiHost/@$username")
|
||||
}
|
||||
|
||||
resultTootAccount = parser.account(jsonObject)
|
||||
r1.data = jsonObject
|
||||
r1.tokenInfo = tokenJson
|
||||
r1
|
||||
}?.let { result ->
|
||||
val sa: SavedAccount? = null
|
||||
if (activity.afterAccountVerify(
|
||||
result,
|
||||
resultTootAccount,
|
||||
sa,
|
||||
apiHost,
|
||||
resultApDomain
|
||||
val tokenJson = auth.createUser(
|
||||
clientInfo,
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
agreement,
|
||||
reason
|
||||
)
|
||||
) {
|
||||
|
||||
val accessToken = tokenJson.string("access_token")
|
||||
?: error("can't get user access token")
|
||||
|
||||
var accountJson = auth.verifyAccount(
|
||||
accessToken = accessToken,
|
||||
outTokenJson = tokenJson,
|
||||
misskeyVersion = misskeyVersion
|
||||
)
|
||||
|
||||
client.apiHost = apiHost
|
||||
val (ti, ri) = TootInstance.getEx(client, forceAccessToken = accessToken)
|
||||
ti ?: error("missing server information. ${ri?.error}")
|
||||
|
||||
val parser = TootParser(
|
||||
activity,
|
||||
linkHelper = LinkHelper.create(ti)
|
||||
)
|
||||
|
||||
var ta = parser.account(accountJson)
|
||||
if (ta == null) {
|
||||
accountJson = buildJsonObject {
|
||||
put("id", EntityId.CONFIRMING.toString())
|
||||
put("username", username)
|
||||
put("acct", username)
|
||||
put("url", "https://$apiHost/@$username")
|
||||
}
|
||||
ta = parser.account(accountJson)!!
|
||||
}
|
||||
Auth2Result(
|
||||
tootInstance = ti,
|
||||
accountJson = accountJson,
|
||||
tootAccount = ta,
|
||||
tokenJson = tokenJson,
|
||||
)
|
||||
}
|
||||
val verified = activity.afterAccountVerify(auth2Result)
|
||||
if (verified) {
|
||||
dialogHost.dismissSafe()
|
||||
dialog_create.dismissSafe()
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
showApiError(ex)
|
||||
}
|
||||
}
|
||||
}.show()
|
||||
|
@ -134,91 +133,74 @@ private fun ActMain.accountCreate(
|
|||
// アカウントの追加
|
||||
fun ActMain.accountAdd() {
|
||||
val activity = this
|
||||
LoginForm.showLoginForm(this, null) { dialog, instance, action ->
|
||||
LoginForm.showLoginForm(this, null) { dialogHost, instance, action ->
|
||||
launchMain {
|
||||
val result = runApiTask(instance) { client ->
|
||||
try {
|
||||
when (action) {
|
||||
|
||||
// ログイン画面を開く
|
||||
LoginForm.Action.Existing ->
|
||||
client.authentication1(PrefS.spClientName(pref))
|
||||
|
||||
LoginForm.Action.Create ->
|
||||
client.createUser1(PrefS.spClientName(pref))
|
||||
|
||||
LoginForm.Action.Pseudo, LoginForm.Action.Token -> {
|
||||
val (ti, ri) = TootInstance.get(client)
|
||||
if (ti != null) ri?.data = ti
|
||||
ri
|
||||
}
|
||||
}
|
||||
} ?: return@launchMain // cancelled.
|
||||
|
||||
val data = result.data
|
||||
if (result.error == null && data != null) {
|
||||
when (action) {
|
||||
LoginForm.Action.Existing -> if (data is String) {
|
||||
// ブラウザ用URLが生成された
|
||||
openBrowser(data.toUri())
|
||||
dialog.dismissSafe()
|
||||
return@launchMain
|
||||
}
|
||||
|
||||
LoginForm.Action.Create -> if (data is JsonObject) {
|
||||
// インスタンスを確認できた
|
||||
accountCreate(instance, data, dialog)
|
||||
return@launchMain
|
||||
}
|
||||
|
||||
LoginForm.Action.Pseudo -> if (data is TootInstance) {
|
||||
addPseudoAccount(instance, data)?.let { a ->
|
||||
showToast(false, R.string.server_confirmed)
|
||||
val pos = activity.appState.columnCount
|
||||
addColumn(pos, a, ColumnType.LOCAL)
|
||||
dialog.dismissSafe()
|
||||
runApiTask2(instance) { client ->
|
||||
val authUri = client.authStep1()
|
||||
withContext(AppDispatchers.MainImmediate) {
|
||||
openBrowser(authUri)
|
||||
dialogHost.dismissSafe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LoginForm.Action.Token -> if (data is TootInstance) {
|
||||
DlgTextInput.show(
|
||||
activity,
|
||||
getString(R.string.access_token_or_api_token),
|
||||
null,
|
||||
callback = object : DlgTextInput.Callback {
|
||||
// ユーザ作成
|
||||
LoginForm.Action.Create ->
|
||||
runApiTask2(instance) { client ->
|
||||
val clientInfo = client.prepareClient()
|
||||
withContext(AppDispatchers.MainImmediate) {
|
||||
accountCreate(instance, clientInfo, dialogHost)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE")
|
||||
override fun onOK(
|
||||
dialog_token: Dialog,
|
||||
text: String,
|
||||
) {
|
||||
|
||||
// dialog引数が二つあるのに注意
|
||||
activity.checkAccessToken(
|
||||
dialog,
|
||||
dialog_token,
|
||||
instance,
|
||||
text,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
override fun onEmptyError() {
|
||||
activity.showToast(true, R.string.token_not_specified)
|
||||
// 疑似アカウント
|
||||
LoginForm.Action.Pseudo ->
|
||||
runApiTask2(instance) { client ->
|
||||
val (ti, ri) = TootInstance.get(client)
|
||||
ti ?: error("${ri?.error}")
|
||||
withContext(AppDispatchers.MainImmediate) {
|
||||
addPseudoAccount(instance, ti)?.let { a ->
|
||||
showToast(false, R.string.server_confirmed)
|
||||
val pos = activity.appState.columnCount
|
||||
addColumn(pos, a, ColumnType.LOCAL)
|
||||
dialogHost.dismissSafe()
|
||||
}
|
||||
}
|
||||
)
|
||||
return@launchMain
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val errorText = result.error ?: "(no error information)"
|
||||
if (isAndroid7TlsBug(errorText)) {
|
||||
AlertDialog.Builder(activity)
|
||||
.setMessage(errorText + "\n\n" + activity.getString(R.string.ssl_bug_7_0))
|
||||
.setNeutralButton(R.string.close, null)
|
||||
.show()
|
||||
} else {
|
||||
activity.showToast(true, "$errorText ${result.requestInfo}".trim())
|
||||
LoginForm.Action.Token ->
|
||||
runApiTask2(instance) { client ->
|
||||
val (ti, ri) = TootInstance.get(client)
|
||||
ti ?: error("${ri?.error}")
|
||||
withContext(AppDispatchers.MainImmediate) {
|
||||
DlgTextInput.show(
|
||||
activity,
|
||||
getString(R.string.access_token_or_api_token),
|
||||
null,
|
||||
callback = object : DlgTextInput.Callback {
|
||||
override fun onEmptyError() {
|
||||
showToast(true, R.string.token_not_specified)
|
||||
}
|
||||
|
||||
override fun onOK(dialog: Dialog, text: String) {
|
||||
// dialog引数が二つあるのに注意
|
||||
activity.checkAccessToken(
|
||||
dialogHost = dialogHost,
|
||||
dialogToken = dialog,
|
||||
apiHost = instance,
|
||||
accessToken = text,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
showApiError(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -422,58 +404,64 @@ fun ActMain.checkAccessToken(
|
|||
dialogToken: Dialog?,
|
||||
apiHost: Host,
|
||||
accessToken: String,
|
||||
sa: SavedAccount?,
|
||||
) {
|
||||
launchMain {
|
||||
var resultAccount: TootAccount? = null
|
||||
var resultApDomain: Host? = null
|
||||
try {
|
||||
val auth2Result = runApiTask2(apiHost) { client ->
|
||||
val (ti, ri) = TootInstance.getEx(client, forceAccessToken = accessToken)
|
||||
ti ?: error("missing uri in Instance Information. ${ri?.error}")
|
||||
|
||||
runApiTask(apiHost) { client ->
|
||||
val (ti, ri) = TootInstance.getEx(client, forceAccessToken = accessToken)
|
||||
ti ?: return@runApiTask ri
|
||||
val tokenJson = JsonObject()
|
||||
|
||||
val apDomain = ti.uri?.let { Host.parse(it) }
|
||||
?: return@runApiTask TootApiResult("missing uri in Instance Information")
|
||||
val userJson = client.getUserCredential(
|
||||
accessToken,
|
||||
outTokenInfo = tokenJson, // 更新される
|
||||
misskeyVersion = ti.misskeyVersionMajor
|
||||
)
|
||||
|
||||
val misskeyVersion = ti.misskeyVersion
|
||||
val parser = TootParser(this, linkHelper = LinkHelper.create(ti))
|
||||
|
||||
client.getUserCredential(accessToken, misskeyVersion = misskeyVersion)
|
||||
?.also { result ->
|
||||
resultApDomain = apDomain
|
||||
resultAccount = TootParser(
|
||||
this,
|
||||
LinkHelper.create(
|
||||
apiHostArg = apiHost,
|
||||
apDomainArg = apDomain,
|
||||
misskeyVersion = misskeyVersion
|
||||
)
|
||||
).account(result.jsonObject)
|
||||
}
|
||||
}?.let { result ->
|
||||
if (afterAccountVerify(result, resultAccount, sa, apiHost, resultApDomain)) {
|
||||
Auth2Result(
|
||||
tootInstance = ti,
|
||||
accountJson = userJson,
|
||||
tootAccount = parser.account(userJson)
|
||||
?: error("can't parse user information."),
|
||||
tokenJson = tokenJson,
|
||||
)
|
||||
}
|
||||
val verified = afterAccountVerify(auth2Result)
|
||||
if (verified) {
|
||||
dialogHost?.dismissSafe()
|
||||
dialogToken?.dismissSafe()
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
showApiError(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// アクセストークンの手動入力(更新)
|
||||
fun ActMain.checkAccessToken2(dbId: Long) {
|
||||
|
||||
val sa = SavedAccount.loadAccount(this, dbId) ?: return
|
||||
val apiHost = SavedAccount.loadAccount(this, dbId)
|
||||
?.apiHost
|
||||
?: return
|
||||
|
||||
DlgTextInput.show(
|
||||
this,
|
||||
getString(R.string.access_token_or_api_token),
|
||||
null,
|
||||
callback = object : DlgTextInput.Callback {
|
||||
override fun onOK(dialog: Dialog, text: String) {
|
||||
checkAccessToken(null, dialog, sa.apiHost, text, sa)
|
||||
}
|
||||
|
||||
override fun onEmptyError() {
|
||||
showToast(true, R.string.token_not_specified)
|
||||
}
|
||||
|
||||
override fun onOK(dialog: Dialog, text: String) {
|
||||
checkAccessToken(
|
||||
dialogHost = null,
|
||||
dialogToken = dialog,
|
||||
apiHost = apiHost,
|
||||
accessToken = text,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -274,7 +274,7 @@ fun ActMain.followHashTag(
|
|||
// 成功時はTagオブジェクトが返る
|
||||
// フォロー中のタグ一覧を更新する
|
||||
TootParser(activity, accessInfo).tag(result.jsonObject)?.let { tag ->
|
||||
withContext(AppDispatchers.mainImmediate) {
|
||||
withContext(AppDispatchers.MainImmediate) {
|
||||
for (column in appState.columnList) {
|
||||
column.onTagFollowChanged(accessInfo, tag)
|
||||
}
|
||||
|
|
|
@ -10,11 +10,14 @@ import jp.juggler.subwaytooter.R
|
|||
import jp.juggler.subwaytooter.action.conversationOtherInstance
|
||||
import jp.juggler.subwaytooter.action.openActPostImpl
|
||||
import jp.juggler.subwaytooter.action.userProfile
|
||||
import jp.juggler.subwaytooter.api.TootApiResult
|
||||
import jp.juggler.subwaytooter.api.TootParser
|
||||
import jp.juggler.subwaytooter.api.entity.*
|
||||
import jp.juggler.subwaytooter.api.auth.Auth2Result
|
||||
import jp.juggler.subwaytooter.api.auth.AuthBase
|
||||
import jp.juggler.subwaytooter.api.entity.Acct
|
||||
import jp.juggler.subwaytooter.api.entity.TootAccount
|
||||
import jp.juggler.subwaytooter.api.entity.TootStatus.Companion.findStatusIdFromUrl
|
||||
import jp.juggler.subwaytooter.api.runApiTask
|
||||
import jp.juggler.subwaytooter.api.entity.TootVisibility
|
||||
import jp.juggler.subwaytooter.api.runApiTask2
|
||||
import jp.juggler.subwaytooter.api.showApiError
|
||||
import jp.juggler.subwaytooter.column.ColumnType
|
||||
import jp.juggler.subwaytooter.column.startLoading
|
||||
import jp.juggler.subwaytooter.dialog.pickAccount
|
||||
|
@ -22,20 +25,13 @@ import jp.juggler.subwaytooter.notification.PushSubscriptionHelper
|
|||
import jp.juggler.subwaytooter.notification.checkNotificationImmediate
|
||||
import jp.juggler.subwaytooter.notification.checkNotificationImmediateAll
|
||||
import jp.juggler.subwaytooter.notification.recycleClickedNotification
|
||||
import jp.juggler.subwaytooter.pref.PrefDevice
|
||||
import jp.juggler.subwaytooter.pref.PrefS
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.subwaytooter.util.LinkHelper
|
||||
import jp.juggler.util.coroutine.launchMain
|
||||
import jp.juggler.util.data.JsonObject
|
||||
import jp.juggler.util.data.decodePercent
|
||||
import jp.juggler.util.data.groupEx
|
||||
import jp.juggler.util.data.notBlank
|
||||
import jp.juggler.util.log.LogCategory
|
||||
import jp.juggler.util.log.showToast
|
||||
import jp.juggler.util.log.withCaption
|
||||
import jp.juggler.util.queryIntentActivitiesCompat
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
private val log = LogCategory("ActMainIntent")
|
||||
|
||||
|
@ -203,188 +199,46 @@ private fun ActMain.handleNotificationClick(uri: Uri, dataIdString: String) {
|
|||
|
||||
private fun ActMain.handleOAuth2Callback(uri: Uri) {
|
||||
launchMain {
|
||||
var resultTootAccount: TootAccount? = null
|
||||
var resultSavedAccount: SavedAccount? = null
|
||||
var resultApiHost: Host? = null
|
||||
var resultApDomain: Host? = null
|
||||
runApiTask { client ->
|
||||
|
||||
val uriStr = uri.toString()
|
||||
if (uriStr.startsWith("subwaytooter://misskey/auth_callback") ||
|
||||
uriStr.startsWith("misskeyclientproto://misskeyclientproto/auth_callback")
|
||||
) {
|
||||
// Misskey 認証コールバック
|
||||
val token = uri.getQueryParameter("token")?.notBlank()
|
||||
?: return@runApiTask TootApiResult("missing token in callback URL")
|
||||
|
||||
val prefDevice = PrefDevice.from(this)
|
||||
|
||||
val hostStr = prefDevice.getString(PrefDevice.LAST_AUTH_INSTANCE, null)?.notBlank()
|
||||
?: return@runApiTask TootApiResult("missing instance name.")
|
||||
|
||||
val instance = Host.parse(hostStr)
|
||||
|
||||
when (val dbId = prefDevice.getLong(PrefDevice.LAST_AUTH_DB_ID, -1L)) {
|
||||
|
||||
// new registration
|
||||
-1L -> client.apiHost = instance
|
||||
|
||||
// update access token
|
||||
else -> try {
|
||||
val sa = SavedAccount.loadAccount(applicationContext, dbId)
|
||||
?: return@runApiTask TootApiResult("missing account db_id=$dbId")
|
||||
resultSavedAccount = sa
|
||||
client.account = sa
|
||||
} catch (ex: Throwable) {
|
||||
log.e(ex, "handleOAuth2Callback failed.")
|
||||
return@runApiTask TootApiResult(ex.withCaption("invalid state"))
|
||||
}
|
||||
}
|
||||
|
||||
val (ti, r2) = TootInstance.get(client)
|
||||
ti ?: return@runApiTask r2
|
||||
|
||||
resultApiHost = instance
|
||||
resultApDomain = ti.uri?.let { Host.parse(it) }
|
||||
|
||||
val parser = TootParser(
|
||||
applicationContext,
|
||||
linkHelper = LinkHelper.create(instance, misskeyVersion = ti.misskeyVersion)
|
||||
)
|
||||
client.authentication2Misskey(PrefS.spClientName(pref), token, ti.misskeyVersion)
|
||||
?.also { resultTootAccount = parser.account(it.jsonObject) }
|
||||
} else {
|
||||
// Mastodon 認証コールバック
|
||||
|
||||
// エラー時
|
||||
// subwaytooter://oauth(\d*)/
|
||||
// ?error=access_denied
|
||||
// &error_description=%E3%83%AA%E3%82%BD%E3%83%BC%E3%82%B9%E3%81%AE%E6%89%80%E6%9C%89%E8%80%85%E3%81%BE%E3%81%9F%E3%81%AF%E8%AA%8D%E8%A8%BC%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E3%81%8C%E8%A6%81%E6%B1%82%E3%82%92%E6%8B%92%E5%90%A6%E3%81%97%E3%81%BE%E3%81%97%E3%81%9F%E3%80%82
|
||||
// &state=db%3A3
|
||||
val error = uri.getQueryParameter("error")
|
||||
val errorDescription = uri.getQueryParameter("error_description")
|
||||
if (error != null || errorDescription != null) {
|
||||
return@runApiTask TootApiResult(
|
||||
errorDescription.notBlank() ?: error.notBlank() ?: "?"
|
||||
)
|
||||
}
|
||||
|
||||
// subwaytooter://oauth(\d*)/
|
||||
// ?code=113cc036e078ac500d3d0d3ad345cd8181456ab087abc67270d40f40a4e9e3c2
|
||||
// &state=host%3Amastodon.juggler.jp
|
||||
|
||||
val code = uri.getQueryParameter("code")?.notBlank()
|
||||
?: return@runApiTask TootApiResult("missing code in callback url.")
|
||||
|
||||
val sv = uri.getQueryParameter("state")?.notBlank()
|
||||
?: return@runApiTask TootApiResult("missing state in callback url.")
|
||||
|
||||
for (param in sv.split(",")) {
|
||||
when {
|
||||
param.startsWith("db:") -> try {
|
||||
val dataId = param.substring(3).toLong(10)
|
||||
val sa = SavedAccount.loadAccount(applicationContext, dataId)
|
||||
?: return@runApiTask TootApiResult("missing account db_id=$dataId")
|
||||
resultSavedAccount = sa
|
||||
client.account = sa
|
||||
} catch (ex: Throwable) {
|
||||
log.e(ex, "handleOAuth2Callback failed.")
|
||||
return@runApiTask TootApiResult(ex.withCaption("invalid state"))
|
||||
}
|
||||
|
||||
param.startsWith("host:") -> {
|
||||
val host = Host.parse(param.substring(5))
|
||||
client.apiHost = host
|
||||
}
|
||||
|
||||
// ignore other parameter
|
||||
}
|
||||
}
|
||||
|
||||
val apiHost = client.apiHost
|
||||
?: return@runApiTask TootApiResult("missing instance in callback url.")
|
||||
|
||||
resultApiHost = apiHost
|
||||
|
||||
val parser = TootParser(
|
||||
applicationContext,
|
||||
linkHelper = LinkHelper.create(apiHost)
|
||||
)
|
||||
|
||||
val refToken = AtomicReference<String>(null)
|
||||
|
||||
client.authentication2Mastodon(
|
||||
PrefS.spClientName(pref),
|
||||
code,
|
||||
outAccessToken = refToken
|
||||
)?.also { result ->
|
||||
val ta = parser.account(result.jsonObject)
|
||||
if (ta != null) {
|
||||
val (ti, ri) = TootInstance.getEx(client, forceAccessToken = refToken.get())
|
||||
ti ?: return@runApiTask ri
|
||||
resultTootAccount = ta
|
||||
resultApDomain = ti.uri?.let { Host.parse(it) }
|
||||
}
|
||||
}
|
||||
try {
|
||||
val auth2Result = runApiTask2 { client ->
|
||||
AuthBase.findAuthForAuthCallback(client, uri.toString())
|
||||
.authStep2(uri)
|
||||
}
|
||||
}?.let { result ->
|
||||
val apiHost = resultApiHost
|
||||
val apDomain = resultApDomain
|
||||
val ta = resultTootAccount
|
||||
var sa = resultSavedAccount
|
||||
if (ta != null && apiHost?.isValid == true && sa == null) {
|
||||
val acct = Acct.parse(ta.username, apDomain ?: apiHost)
|
||||
// アカウント追加時に、アプリ内に既にあるアカウントと同じものを登録していたかもしれない
|
||||
sa = SavedAccount.loadAccountByAcct(applicationContext, acct.ascii)
|
||||
}
|
||||
afterAccountVerify(result, ta, sa, apiHost, apDomain)
|
||||
afterAccountVerify(auth2Result)
|
||||
} catch (ex: Throwable) {
|
||||
showApiError(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ActMain.afterAccountVerify(
|
||||
result: TootApiResult?,
|
||||
ta: TootAccount?,
|
||||
sa: SavedAccount?,
|
||||
apiHost: Host?,
|
||||
apDomain: Host?,
|
||||
): Boolean {
|
||||
result ?: return false
|
||||
/**
|
||||
* アカウントを確認した後に呼ばれる
|
||||
* @return 何かデータを更新したら真
|
||||
*/
|
||||
fun ActMain.afterAccountVerify(auth2Result: Auth2Result): Boolean = auth2Result.run {
|
||||
|
||||
val jsonObject = result.jsonObject
|
||||
val tokenInfo = result.tokenInfo
|
||||
val error = result.error
|
||||
// ユーザ情報中のacctはfull acct ではないので、組み立てる
|
||||
val newAcct = Acct.parse(tootAccount.username, apDomain)
|
||||
|
||||
when {
|
||||
error != null -> showToast(true, "${result.error} ${result.requestInfo}".trim())
|
||||
tokenInfo == null -> showToast(true, "can't get access token.")
|
||||
jsonObject == null -> showToast(true, "can't parse json response.")
|
||||
// full acctだよな?
|
||||
"""\A[^@]+@[^@]+\z""".toRegex().find(newAcct.ascii)
|
||||
?: error("afterAccountAdd: incorrect userAcct. ${newAcct.ascii}")
|
||||
|
||||
// 自分のユーザネームを取れなかった
|
||||
// …普通はエラーメッセージが設定されてるはずだが
|
||||
ta == null -> showToast(true, "can't verify user credential.")
|
||||
|
||||
// アクセストークン更新時
|
||||
// インスタンスは同じだと思うが、ユーザ名が異なる可能性がある
|
||||
sa != null -> return afterAccessTokenUpdate(ta, sa, tokenInfo)
|
||||
|
||||
apiHost != null -> return afterAccountAdd(apDomain, apiHost, ta, jsonObject, tokenInfo)
|
||||
// 「アカウント追加のハズが既存アカウントで認証していた」
|
||||
// 「アクセストークン更新のハズが別アカウントで認証していた」
|
||||
// などを防止するため、full acctでアプリ内DBを検索
|
||||
when (val sa = SavedAccount.loadAccountByAcct(this@afterAccountVerify, newAcct.ascii)) {
|
||||
null -> afterAccountAdd(newAcct, auth2Result)
|
||||
else -> afterAccessTokenUpdate(auth2Result, sa)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun ActMain.afterAccessTokenUpdate(
|
||||
ta: TootAccount,
|
||||
auth2Result: Auth2Result,
|
||||
sa: SavedAccount,
|
||||
tokenInfo: JsonObject?,
|
||||
): Boolean {
|
||||
if (sa.username != ta.username) {
|
||||
showToast(true, R.string.user_name_not_match)
|
||||
return false
|
||||
}
|
||||
|
||||
// DBの情報を更新する
|
||||
sa.updateTokenInfo(tokenInfo)
|
||||
sa.updateTokenInfo(auth2Result)
|
||||
|
||||
// 各カラムの持つアカウント情報をリロードする
|
||||
reloadAccountSetting()
|
||||
|
@ -404,22 +258,18 @@ private fun ActMain.afterAccessTokenUpdate(
|
|||
}
|
||||
|
||||
private fun ActMain.afterAccountAdd(
|
||||
apDomain: Host?,
|
||||
apiHost: Host,
|
||||
ta: TootAccount,
|
||||
jsonObject: JsonObject,
|
||||
tokenInfo: JsonObject,
|
||||
newAcct: Acct,
|
||||
auth2Result: Auth2Result,
|
||||
): Boolean {
|
||||
// アカウント追加時
|
||||
val user = Acct.parse(ta.username, apDomain ?: apiHost)
|
||||
val ta = auth2Result.tootAccount
|
||||
|
||||
val rowId = SavedAccount.insert(
|
||||
acct = user.ascii,
|
||||
host = apiHost.ascii,
|
||||
domain = (apDomain ?: apiHost).ascii,
|
||||
account = jsonObject,
|
||||
token = tokenInfo,
|
||||
misskeyVersion = TootInstance.parseMisskeyVersion(tokenInfo)
|
||||
acct = newAcct.ascii,
|
||||
host = auth2Result.apiHost.ascii,
|
||||
domain = auth2Result.apDomain.ascii,
|
||||
account = auth2Result.accountJson,
|
||||
token = auth2Result.tokenJson,
|
||||
misskeyVersion = auth2Result.tootInstance.misskeyVersionMajor,
|
||||
)
|
||||
val account = SavedAccount.loadAccount(applicationContext, rowId)
|
||||
if (account == null) {
|
||||
|
|
|
@ -198,7 +198,7 @@ class SideMenuAdapter(
|
|||
?: error("missing appVersion json")
|
||||
releaseInfo = json
|
||||
versionText = createVersionRow()
|
||||
withContext(AppDispatchers.mainImmediate) {
|
||||
withContext(AppDispatchers.MainImmediate) {
|
||||
lastVersionView?.get()?.text = versionText
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
|
|
|
@ -71,7 +71,7 @@ private class TootTaskRunner(
|
|||
try {
|
||||
openProgress()
|
||||
supervisorScope {
|
||||
async(AppDispatchers.io) {
|
||||
async(AppDispatchers.IO) {
|
||||
backgroundBlock(context, client)
|
||||
}.also {
|
||||
task = it
|
||||
|
|
|
@ -0,0 +1,256 @@
|
|||
package jp.juggler.subwaytooter.api
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import jp.juggler.subwaytooter.App1
|
||||
import jp.juggler.subwaytooter.api.entity.Host
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.util.coroutine.AppDispatchers
|
||||
import jp.juggler.util.data.clip
|
||||
import jp.juggler.util.ui.ProgressDialogEx
|
||||
import jp.juggler.util.ui.dismissSafe
|
||||
import kotlinx.coroutines.*
|
||||
import java.lang.Runnable
|
||||
import java.lang.ref.WeakReference
|
||||
import java.text.NumberFormat
|
||||
|
||||
/*
|
||||
APIクライアントを必要とする非同期タスク(TootTask)を実行します。
|
||||
- ProgressDialogを表示します。抑制することも可能です。
|
||||
- TootApiClientの初期化を行います
|
||||
- TootApiClientからの進捗イベントをProgressDialogに伝達します。
|
||||
*/
|
||||
interface ApiTask2 {
|
||||
val isActive: Any
|
||||
|
||||
companion object {
|
||||
val defaultProgressSetupCallback: (progress: ProgressDialogEx) -> Unit = { }
|
||||
|
||||
const val PROGRESS_NONE = -1
|
||||
const val PROGRESS_SPINNER = ProgressDialogEx.STYLE_SPINNER
|
||||
const val PROGRESS_HORIZONTAL = ProgressDialogEx.STYLE_HORIZONTAL
|
||||
}
|
||||
}
|
||||
|
||||
private class TootTaskRunner2<ReturnType : Any?>(
|
||||
context: Context,
|
||||
private val progressStyle: Int = ApiTask.PROGRESS_SPINNER,
|
||||
private val progressPrefix: String? = null,
|
||||
private val progressSetupCallback: (progress: ProgressDialogEx) -> Unit = ApiTask.defaultProgressSetupCallback,
|
||||
) : TootApiCallback, ApiTask {
|
||||
|
||||
companion object {
|
||||
|
||||
// private val log = LogCategory("TootTaskRunner")
|
||||
|
||||
// caller will be in launchMain{} coroutine.
|
||||
suspend fun <T : Any?, A : Context> runApiTask(
|
||||
context: A,
|
||||
accessInfo: SavedAccount? = null,
|
||||
apiHost: Host? = null,
|
||||
progressStyle: Int = ApiTask.PROGRESS_SPINNER,
|
||||
progressPrefix: String? = null,
|
||||
progressSetup: (progress: ProgressDialogEx) -> Unit = ApiTask.defaultProgressSetupCallback,
|
||||
backgroundBlock: suspend A.(client: TootApiClient) -> T,
|
||||
) = withContext(AppDispatchers.MainImmediate) {
|
||||
TootTaskRunner2<T>(
|
||||
context = context,
|
||||
progressStyle = progressStyle,
|
||||
progressPrefix = progressPrefix,
|
||||
progressSetupCallback = progressSetup
|
||||
).run {
|
||||
accessInfo?.let { client.account = it }
|
||||
apiHost?.let { client.apiHost = it }
|
||||
try {
|
||||
openProgress()
|
||||
supervisorScope {
|
||||
async(AppDispatchers.IO) {
|
||||
backgroundBlock(context, client)
|
||||
}.also {
|
||||
task = it
|
||||
}.await()
|
||||
}
|
||||
} finally {
|
||||
dismissProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val percent_format: NumberFormat by lazy {
|
||||
val v = NumberFormat.getPercentInstance()
|
||||
v.maximumFractionDigits = 0
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
private class ProgressInfo {
|
||||
|
||||
// HORIZONTALスタイルの場合、初期メッセージがないと後からメッセージを指定しても表示されない
|
||||
var message = " "
|
||||
var isIndeterminate = true
|
||||
var value = 0
|
||||
var max = 1
|
||||
}
|
||||
|
||||
val client = TootApiClient(context, callback = this)
|
||||
|
||||
private val handler = App1.getAppState(context, "TootTaskRunner.ctor").handler
|
||||
private val info = ProgressInfo()
|
||||
private var progress: ProgressDialogEx? = null
|
||||
private var task: Deferred<ReturnType>? = null
|
||||
private val refContext = WeakReference(context)
|
||||
private var lastMessageShown = 0L
|
||||
|
||||
private val procProgressMessage = Runnable {
|
||||
if (progress?.isShowing == true) showProgressMessage()
|
||||
}
|
||||
|
||||
override val isActive: Boolean
|
||||
get() = task?.isActive ?: true // nullはまだ開始してないのでアクティブということにする
|
||||
|
||||
fun cancel() {
|
||||
task?.cancel()
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
// implements TootApiClient.Callback
|
||||
|
||||
override suspend fun isApiCancelled() = task?.isActive == false
|
||||
|
||||
override suspend fun publishApiProgress(s: String) {
|
||||
synchronized(this) {
|
||||
info.message = s
|
||||
info.isIndeterminate = true
|
||||
}
|
||||
delayProgressMessage()
|
||||
}
|
||||
|
||||
override suspend fun publishApiProgressRatio(value: Int, max: Int) {
|
||||
synchronized(this) {
|
||||
info.isIndeterminate = false
|
||||
info.value = value
|
||||
info.max = max
|
||||
}
|
||||
delayProgressMessage()
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
// ProgressDialog
|
||||
|
||||
private fun openProgress() {
|
||||
// open progress
|
||||
if (progressStyle != ApiTask.PROGRESS_NONE) {
|
||||
val context = refContext.get()
|
||||
if (context != null && context is Activity) {
|
||||
val progress = ProgressDialogEx(context)
|
||||
this.progress = progress
|
||||
progress.setCancelable(true)
|
||||
progress.setOnCancelListener { task?.cancel() }
|
||||
@Suppress("DEPRECATION")
|
||||
progress.setProgressStyle(progressStyle)
|
||||
progressSetupCallback(progress)
|
||||
showProgressMessage()
|
||||
progress.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ダイアログを閉じる
|
||||
private fun dismissProgress() {
|
||||
progress?.dismissSafe()
|
||||
progress = null
|
||||
}
|
||||
|
||||
// ダイアログのメッセージを更新する
|
||||
// 初期化時とメッセージ更新時に呼ばれる
|
||||
@Suppress("DEPRECATION")
|
||||
private fun showProgressMessage() {
|
||||
val progress = this.progress ?: return
|
||||
|
||||
synchronized(this) {
|
||||
val message = info.message.trim { it <= ' ' }
|
||||
val progressPrefix = this.progressPrefix
|
||||
progress.setMessageEx(
|
||||
when {
|
||||
progressPrefix?.isNotEmpty() != true -> message
|
||||
message.isEmpty() -> progressPrefix
|
||||
else -> "$progressPrefix\n$message"
|
||||
}
|
||||
)
|
||||
|
||||
progress.isIndeterminateEx = info.isIndeterminate
|
||||
if (info.isIndeterminate) {
|
||||
progress.setProgressNumberFormat(null)
|
||||
progress.setProgressPercentFormat(null)
|
||||
} else {
|
||||
progress.progress = info.value
|
||||
progress.max = info.max
|
||||
progress.setProgressNumberFormat("%1$,d / %2$,d")
|
||||
progress.setProgressPercentFormat(percent_format)
|
||||
}
|
||||
|
||||
lastMessageShown = SystemClock.elapsedRealtime()
|
||||
}
|
||||
}
|
||||
|
||||
// 少し後にダイアログのメッセージを更新する
|
||||
// あまり頻繁に更新せず、しかし繰り返し呼ばれ続けても時々は更新したい
|
||||
// どのスレッドから呼ばれるか分からない
|
||||
private fun delayProgressMessage() {
|
||||
var wait = 100L + lastMessageShown - SystemClock.elapsedRealtime()
|
||||
wait = wait.clip(0L, 100L)
|
||||
|
||||
synchronized(this) {
|
||||
handler.removeCallbacks(procProgressMessage)
|
||||
handler.postDelayed(procProgressMessage, wait)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <T : Any?,A : Context> A.runApiTask2(
|
||||
accessInfo: SavedAccount,
|
||||
progressStyle: Int = ApiTask.PROGRESS_SPINNER,
|
||||
progressPrefix: String? = null,
|
||||
progressSetup: (progress: ProgressDialogEx) -> Unit = ApiTask.defaultProgressSetupCallback,
|
||||
backgroundBlock: suspend A.(client: TootApiClient) -> T,
|
||||
) = TootTaskRunner2.runApiTask(
|
||||
this,
|
||||
accessInfo,
|
||||
null,
|
||||
progressStyle,
|
||||
progressPrefix,
|
||||
progressSetup,
|
||||
backgroundBlock
|
||||
)
|
||||
|
||||
suspend fun <T : Any?, A : Context> A.runApiTask2(
|
||||
apiHost: Host,
|
||||
progressStyle: Int = ApiTask.PROGRESS_SPINNER,
|
||||
progressPrefix: String? = null,
|
||||
progressSetup: (progress: ProgressDialogEx) -> Unit = ApiTask.defaultProgressSetupCallback,
|
||||
backgroundBlock: suspend A.(client: TootApiClient) -> T,
|
||||
) = TootTaskRunner2.runApiTask(
|
||||
this,
|
||||
null,
|
||||
apiHost,
|
||||
progressStyle,
|
||||
progressPrefix,
|
||||
progressSetup,
|
||||
backgroundBlock
|
||||
)
|
||||
|
||||
suspend fun <T : Any?, A : Context> A.runApiTask2(
|
||||
progressStyle: Int = ApiTask.PROGRESS_SPINNER,
|
||||
progressPrefix: String? = null,
|
||||
progressSetup: (progress: ProgressDialogEx) -> Unit = ApiTask.defaultProgressSetupCallback,
|
||||
backgroundBlock: suspend A.(client: TootApiClient) -> T,
|
||||
) = TootTaskRunner2.runApiTask(
|
||||
this,
|
||||
null,
|
||||
null,
|
||||
progressStyle,
|
||||
progressPrefix,
|
||||
progressSetup,
|
||||
backgroundBlock
|
||||
)
|
|
@ -1,26 +1,20 @@
|
|||
package jp.juggler.subwaytooter.api
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import jp.juggler.subwaytooter.App1
|
||||
import jp.juggler.subwaytooter.R
|
||||
import jp.juggler.subwaytooter.api.auth.AuthBase
|
||||
import jp.juggler.subwaytooter.api.entity.*
|
||||
import jp.juggler.subwaytooter.pref.PrefDevice
|
||||
import jp.juggler.subwaytooter.pref.pref
|
||||
import jp.juggler.subwaytooter.table.ClientInfo
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.subwaytooter.util.*
|
||||
import jp.juggler.util.*
|
||||
import jp.juggler.util.data.*
|
||||
import jp.juggler.util.log.LogCategory
|
||||
import jp.juggler.util.log.showToast
|
||||
import jp.juggler.util.log.withCaption
|
||||
import jp.juggler.util.network.toFormRequestBody
|
||||
import jp.juggler.util.network.toPost
|
||||
import jp.juggler.util.network.toPostRequestBuilder
|
||||
import okhttp3.*
|
||||
import okhttp3.internal.closeQuietly
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class TootApiClient(
|
||||
val context: Context,
|
||||
|
@ -32,110 +26,14 @@ class TootApiClient(
|
|||
|
||||
private val log = LogCategory("TootApiClient")
|
||||
|
||||
private const val DEFAULT_CLIENT_NAME = "SubwayTooter"
|
||||
private const val REDIRECT_URL = "subwaytooter://oauth/"
|
||||
|
||||
// 20181225 3=>4 client credentialの取得時にもscopeの取得が必要になった
|
||||
// 20190147 4=>5 client id とユーザIDが同じだと同じアクセストークンが返ってくるので複数端末の利用で困る。
|
||||
// AUTH_VERSIONが古いclient情報は使わない。また、インポートの対象にしない。
|
||||
private const val AUTH_VERSION = 5
|
||||
|
||||
internal const val KEY_CLIENT_CREDENTIAL = "SubwayTooterClientCredential"
|
||||
internal const val KEY_CLIENT_SCOPE = "SubwayTooterClientScope"
|
||||
private const val KEY_AUTH_VERSION = "SubwayTooterAuthVersion"
|
||||
const val KEY_IS_MISSKEY = "isMisskey" // for ClientInfo
|
||||
const val KEY_MISSKEY_VERSION = "isMisskey" // for tokenInfo,TootInstance
|
||||
const val KEY_MISSKEY_APP_SECRET = "secret"
|
||||
const val KEY_API_KEY_MISSKEY = "apiKeyMisskey"
|
||||
const val KEY_USER_ID = "userId"
|
||||
|
||||
private const val NO_INFORMATION = "(no information)"
|
||||
|
||||
private val reStartJsonArray = """\A\s*\[""".asciiPattern()
|
||||
private val reStartJsonObject = """\A\s*\{""".asciiPattern()
|
||||
val reStartJsonArray = """\A\s*\[""".asciiRegex()
|
||||
val reStartJsonObject = """\A\s*\{""".asciiRegex()
|
||||
|
||||
val DEFAULT_JSON_ERROR_PARSER =
|
||||
{ json: JsonObject -> json["error"]?.toString() }
|
||||
|
||||
fun getScopeString(ti: TootInstance?) = when {
|
||||
// 古いサーバ
|
||||
ti?.versionGE(TootInstance.VERSION_2_4_0_rc1) == false -> "read+write+follow"
|
||||
// 新しいサーバか、AUTHORIZED_FETCH(3.0.0以降)によりサーバ情報を取得できなかった
|
||||
else -> "read+write+follow+push"
|
||||
|
||||
// 過去の試行錯誤かな
|
||||
// ti.versionGE(TootInstance.VERSION_2_7_0_rc1) -> "read+write+follow+push+create"
|
||||
}
|
||||
|
||||
fun getScopeArrayMisskey(@Suppress("UNUSED_PARAMETER") ti: TootInstance) =
|
||||
buildJsonArray {
|
||||
if (ti.versionGE(TootInstance.MISSKEY_VERSION_11)) {
|
||||
// https://github.com/syuilo/misskey/blob/master/src/server/api/kinds.ts
|
||||
arrayOf(
|
||||
"read:account",
|
||||
"write:account",
|
||||
"read:blocks",
|
||||
"write:blocks",
|
||||
"read:drive",
|
||||
"write:drive",
|
||||
"read:favorites",
|
||||
"write:favorites",
|
||||
"read:following",
|
||||
"write:following",
|
||||
"read:messaging",
|
||||
"write:messaging",
|
||||
"read:mutes",
|
||||
"write:mutes",
|
||||
"write:notes",
|
||||
"read:notifications",
|
||||
"write:notifications",
|
||||
"read:reactions",
|
||||
"write:reactions",
|
||||
"write:votes"
|
||||
)
|
||||
} else {
|
||||
// https://github.com/syuilo/misskey/issues/2341
|
||||
arrayOf(
|
||||
"account-read",
|
||||
"account-write",
|
||||
"account/read",
|
||||
"account/write",
|
||||
"drive-read",
|
||||
"drive-write",
|
||||
"favorite-read",
|
||||
"favorite-write",
|
||||
"favorites-read",
|
||||
"following-read",
|
||||
"following-write",
|
||||
"messaging-read",
|
||||
"messaging-write",
|
||||
"note-read",
|
||||
"note-write",
|
||||
"notification-read",
|
||||
"notification-write",
|
||||
"reaction-read",
|
||||
"reaction-write",
|
||||
"vote-read",
|
||||
"vote-write"
|
||||
|
||||
)
|
||||
}
|
||||
// APIのエラーを回避するため、重複を排除する
|
||||
.toMutableSet()
|
||||
.forEach { add(it) }
|
||||
}
|
||||
|
||||
private fun encodeScopeArray(scopeArray: JsonArray?): String? {
|
||||
scopeArray ?: return null
|
||||
val list = scopeArray.stringArrayList()
|
||||
list.sort()
|
||||
return list.joinToString(",")
|
||||
}
|
||||
|
||||
private fun compareScopeArray(a: JsonArray, b: JsonArray?): Boolean {
|
||||
return encodeScopeArray(a) == encodeScopeArray(b)
|
||||
}
|
||||
|
||||
fun formatResponse(
|
||||
response: Response,
|
||||
caption: String = "?",
|
||||
|
@ -233,7 +131,7 @@ class TootApiClient(
|
|||
suspend inline fun sendRequest(
|
||||
result: TootApiResult,
|
||||
progressPath: String? = null,
|
||||
tmpOkhttpClient: OkHttpClient? = null,
|
||||
overrideClient: OkHttpClient? = null,
|
||||
block: () -> Request,
|
||||
): Boolean {
|
||||
|
||||
|
@ -251,7 +149,7 @@ class TootApiClient(
|
|||
)
|
||||
)
|
||||
|
||||
val response = httpClient.getResponse(request, tmpOkhttpClient = tmpOkhttpClient)
|
||||
val response = httpClient.getResponse(request, overrideClient = overrideClient)
|
||||
result.response = response
|
||||
|
||||
null == result.error
|
||||
|
@ -407,11 +305,11 @@ class TootApiClient(
|
|||
result.data = JsonObject()
|
||||
}
|
||||
|
||||
reStartJsonArray.matcher(bodyString).find() -> {
|
||||
reStartJsonArray.find(bodyString) != null -> {
|
||||
result.data = bodyString.decodeJsonArray()
|
||||
}
|
||||
|
||||
reStartJsonObject.matcher(bodyString).find() -> {
|
||||
reStartJsonObject.find(bodyString) != null -> {
|
||||
val json = bodyString.decodeJsonObject()
|
||||
val errorMessage = jsonErrorParser(json)
|
||||
if (errorMessage != null) {
|
||||
|
@ -498,7 +396,7 @@ class TootApiClient(
|
|||
requestBuilder: Request.Builder = Request.Builder(),
|
||||
forceAccessToken: String? = null,
|
||||
): TootApiResult? {
|
||||
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
|
||||
val result = TootApiResult.makeWithCaption(apiHost)
|
||||
if (result.error != null) return result
|
||||
|
||||
val account = this.account // may null
|
||||
|
@ -524,677 +422,43 @@ class TootApiClient(
|
|||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// misskey authentication
|
||||
|
||||
private suspend fun getAppInfoMisskey(appId: String?): TootApiResult? {
|
||||
appId ?: return TootApiResult("missing app id")
|
||||
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
|
||||
if (result.error != null) return result
|
||||
if (sendRequest(result) {
|
||||
jsonObjectOf("appId" to appId)
|
||||
.toPostRequestBuilder()
|
||||
.url("https://${apiHost?.ascii}/api/app/show")
|
||||
.build()
|
||||
}) {
|
||||
parseJson(result) ?: return null
|
||||
result.jsonObject?.put(KEY_IS_MISSKEY, true)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun prepareBrowserUrlMisskey(appSecret: String): String? {
|
||||
|
||||
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
|
||||
|
||||
if (result.error != null) {
|
||||
context.showToast(false, result.error)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!sendRequest(result) {
|
||||
JsonObject().apply {
|
||||
put("appSecret", appSecret)
|
||||
}
|
||||
.toPostRequestBuilder()
|
||||
.url("https://${apiHost?.ascii}/api/auth/session/generate")
|
||||
.build()
|
||||
}
|
||||
) {
|
||||
val error = result.error
|
||||
if (error != null) {
|
||||
context.showToast(false, error)
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
parseJson(result) ?: return null
|
||||
|
||||
val jsonObject = result.jsonObject
|
||||
if (jsonObject == null) {
|
||||
context.showToast(false, result.error)
|
||||
return null
|
||||
}
|
||||
// {"token":"0ba88e2d-4b7d-4599-8d90-dc341a005637","url":"https://misskey.xyz/auth/0ba88e2d-4b7d-4599-8d90-dc341a005637"}
|
||||
|
||||
// ブラウザで開くURL
|
||||
val url = jsonObject.string("url")
|
||||
if (url?.isEmpty() != false) {
|
||||
context.showToast(false, "missing 'url' in auth session response.")
|
||||
return null
|
||||
}
|
||||
|
||||
val e = PrefDevice.from(context)
|
||||
.edit()
|
||||
.putString(PrefDevice.LAST_AUTH_INSTANCE, apiHost?.ascii)
|
||||
.putString(PrefDevice.LAST_AUTH_SECRET, appSecret)
|
||||
|
||||
val account = this.account
|
||||
if (account != null) {
|
||||
e.putLong(PrefDevice.LAST_AUTH_DB_ID, account.db_id)
|
||||
} else {
|
||||
e.remove(PrefDevice.LAST_AUTH_DB_ID)
|
||||
}
|
||||
|
||||
e.apply()
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
private suspend fun registerClientMisskey(
|
||||
scopeArray: JsonArray,
|
||||
clientName: String,
|
||||
): TootApiResult? {
|
||||
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
|
||||
if (result.error != null) return result
|
||||
if (sendRequest(result) {
|
||||
JsonObject().apply {
|
||||
put("nameId", "SubwayTooter")
|
||||
put("name", clientName)
|
||||
put("description", "Android app for federated SNS")
|
||||
put("callbackUrl", "subwaytooter://misskey/auth_callback")
|
||||
put("permission", scopeArray)
|
||||
}
|
||||
.toPostRequestBuilder()
|
||||
.url("https://${apiHost?.ascii}/api/app/create")
|
||||
.build()
|
||||
}) {
|
||||
parseJson(result) ?: return null
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun authentication1Misskey(
|
||||
clientNameArg: String,
|
||||
ti: TootInstance,
|
||||
): TootApiResult? {
|
||||
val result = TootApiResult.makeWithCaption(this.apiHost?.pretty)
|
||||
if (result.error != null) return result
|
||||
val instance = result.caption // same to instance
|
||||
|
||||
// クライアントIDがアプリ上に保存されているか?
|
||||
val clientName = clientNameArg.notEmpty() ?: DEFAULT_CLIENT_NAME
|
||||
val clientInfo = ClientInfo.load(instance, clientName)
|
||||
|
||||
// スコープ一覧を取得する
|
||||
val scopeArray = getScopeArrayMisskey(ti)
|
||||
|
||||
if (clientInfo != null &&
|
||||
AUTH_VERSION == clientInfo.int(KEY_AUTH_VERSION) &&
|
||||
clientInfo.boolean(KEY_IS_MISSKEY) == true
|
||||
) {
|
||||
val appSecret = clientInfo.string(KEY_MISSKEY_APP_SECRET)
|
||||
|
||||
val r2 = getAppInfoMisskey(clientInfo.string("id"))
|
||||
val tmpClientInfo = r2?.jsonObject
|
||||
// tmpClientInfo はsecretを含まないので保存してはいけない
|
||||
when {
|
||||
// アプリが登録済みで
|
||||
// クライアント名が一致してて
|
||||
// パーミッションが同じ
|
||||
tmpClientInfo != null &&
|
||||
clientName == tmpClientInfo.string("name") &&
|
||||
compareScopeArray(scopeArray, tmpClientInfo["permission"].cast()) &&
|
||||
appSecret?.isNotEmpty() == true -> {
|
||||
// クライアント情報を再利用する
|
||||
result.data = prepareBrowserUrlMisskey(appSecret)
|
||||
return result
|
||||
}
|
||||
}
|
||||
// XXX appSecretを使ってクライアント情報を削除できるようにするべきだが、該当するAPIが存在しない
|
||||
}
|
||||
|
||||
val r2 = registerClientMisskey(scopeArray, clientName)
|
||||
val jsonObject = r2?.jsonObject ?: return r2
|
||||
|
||||
val appSecret = jsonObject.string(KEY_MISSKEY_APP_SECRET)
|
||||
if (appSecret?.isEmpty() != false) {
|
||||
context.showToast(true, context.getString(R.string.cant_get_misskey_app_secret))
|
||||
return null
|
||||
}
|
||||
// {
|
||||
// "createdAt": "2018-08-19T00:43:10.105Z",
|
||||
// "userId": null,
|
||||
// "name": "Via芸",
|
||||
// "nameId": "test1",
|
||||
// "description": "test1",
|
||||
// "permission": [
|
||||
// "account-read",
|
||||
// "account-write",
|
||||
// "note-write",
|
||||
// "reaction-write",
|
||||
// "following-write",
|
||||
// "drive-read",
|
||||
// "drive-write",
|
||||
// "notification-read",
|
||||
// "notification-write"
|
||||
// ],
|
||||
// "callbackUrl": "test1://test1/auth_callback",
|
||||
// "id": "5b78bd1ea0db0527f25815c3",
|
||||
// "iconUrl": "https://misskey.xyz/files/app-default.jpg"
|
||||
// }
|
||||
|
||||
// 2018/8/19現在、/api/app/create のレスポンスにsecretが含まれないので認証に使えない
|
||||
// https://github.com/syuilo/misskey/issues/2343
|
||||
|
||||
jsonObject[KEY_IS_MISSKEY] = true
|
||||
jsonObject[KEY_AUTH_VERSION] = AUTH_VERSION
|
||||
ClientInfo.save(instance, clientName, jsonObject.toString())
|
||||
result.data = prepareBrowserUrlMisskey(appSecret)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// oAuth2認証の続きを行う
|
||||
suspend fun authentication2Misskey(
|
||||
clientNameArg: String,
|
||||
token: String,
|
||||
misskeyVersion: Int,
|
||||
): TootApiResult? {
|
||||
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
|
||||
if (result.error != null) return result
|
||||
val instance = result.caption // same to instance
|
||||
val clientName = clientNameArg.notEmpty() ?: DEFAULT_CLIENT_NAME
|
||||
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val clientInfo = ClientInfo.load(instance, clientName)
|
||||
?: return result.setError("missing client id")
|
||||
|
||||
val appSecret = clientInfo.string(KEY_MISSKEY_APP_SECRET)
|
||||
if (appSecret?.isEmpty() != false) {
|
||||
return result.setError(context.getString(R.string.cant_get_misskey_app_secret))
|
||||
}
|
||||
|
||||
if (!sendRequest(result) {
|
||||
JsonObject().apply {
|
||||
put("appSecret", appSecret)
|
||||
put("token", token)
|
||||
}
|
||||
.toPostRequestBuilder()
|
||||
.url("https://$instance/api/auth/session/userkey")
|
||||
.build()
|
||||
}
|
||||
) {
|
||||
return result
|
||||
}
|
||||
|
||||
parseJson(result) ?: return null
|
||||
|
||||
val tokenInfo = result.jsonObject ?: return result
|
||||
|
||||
// {"accessToken":"...","user":{…}}
|
||||
|
||||
val accessToken = tokenInfo.string("accessToken")
|
||||
if (accessToken?.isEmpty() != false) {
|
||||
return result.setError("missing accessToken in the response.")
|
||||
}
|
||||
|
||||
val user = tokenInfo["user"].cast<JsonObject>()
|
||||
?: return result.setError("missing user in the response.")
|
||||
|
||||
tokenInfo.remove("user")
|
||||
|
||||
val apiKey = "$accessToken$appSecret".encodeUTF8().digestSHA256().encodeHexLower()
|
||||
|
||||
// ユーザ情報を読めたならtokenInfoを保存する
|
||||
EntityId.mayNull(user.string("id"))?.putTo(tokenInfo, KEY_USER_ID)
|
||||
tokenInfo[KEY_MISSKEY_VERSION] = misskeyVersion
|
||||
tokenInfo[KEY_AUTH_VERSION] = AUTH_VERSION
|
||||
tokenInfo[KEY_API_KEY_MISSKEY] = apiKey
|
||||
|
||||
// tokenInfoとユーザ情報の入ったresultを返す
|
||||
result.tokenInfo = tokenInfo
|
||||
result.data = user
|
||||
return result
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
|
||||
// クライアントをタンスに登録
|
||||
suspend fun registerClient(scopeString: String, clientName: String): TootApiResult? {
|
||||
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
|
||||
if (result.error != null) return result
|
||||
val instance = result.caption // same to instance
|
||||
// OAuth2 クライアント登録
|
||||
if (!sendRequest(result) {
|
||||
"client_name=${
|
||||
clientName.encodePercent()
|
||||
}&redirect_uris=${
|
||||
REDIRECT_URL.encodePercent()
|
||||
}&scopes=$scopeString"
|
||||
.toFormRequestBody().toPost()
|
||||
.url("https://$instance/api/v1/apps")
|
||||
.build()
|
||||
}) return result
|
||||
|
||||
return parseJson(result)
|
||||
}
|
||||
|
||||
// クライアントアプリの登録を確認するためのトークンを生成する
|
||||
// oAuth2 Client Credentials の取得
|
||||
// https://github.com/doorkeeper-gem/doorkeeper/wiki/Client-Credentials-flow
|
||||
// このトークンはAPIを呼び出すたびに新しく生成される…
|
||||
internal suspend fun getClientCredential(clientInfo: JsonObject): TootApiResult? {
|
||||
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
|
||||
if (result.error != null) return result
|
||||
|
||||
if (!sendRequest(result) {
|
||||
|
||||
val clientId = clientInfo.string("client_id")
|
||||
?: return result.setError("missing client_id")
|
||||
|
||||
val clientSecret = clientInfo.string("client_secret")
|
||||
?: return result.setError("missing client_secret")
|
||||
|
||||
"grant_type=client_credentials&scope=read+write&client_id=${clientId.encodePercent()}&client_secret=${clientSecret.encodePercent()}&redirect_uri=${REDIRECT_URL.encodePercent()}"
|
||||
.toFormRequestBody().toPost()
|
||||
.url("https://${apiHost?.ascii}/oauth/token")
|
||||
.build()
|
||||
}) return result
|
||||
|
||||
val r2 = parseJson(result)
|
||||
val jsonObject = r2?.jsonObject ?: return r2
|
||||
|
||||
log.d("getClientCredential: $jsonObject")
|
||||
|
||||
val sv = jsonObject.string("access_token")?.notEmpty()
|
||||
if (sv != null) {
|
||||
result.data = sv
|
||||
} else {
|
||||
result.data = null
|
||||
result.error = "missing client credential."
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// client_credentialがまだ有効か調べる
|
||||
internal suspend fun verifyClientCredential(clientCredential: String): TootApiResult? {
|
||||
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
|
||||
if (result.error != null) return result
|
||||
|
||||
if (!sendRequest(result) {
|
||||
Request.Builder()
|
||||
.url("https://${apiHost?.ascii}/api/v1/apps/verify_credentials")
|
||||
.header("Authorization", "Bearer $clientCredential")
|
||||
.build()
|
||||
}) return result
|
||||
|
||||
return parseJson(result)
|
||||
}
|
||||
|
||||
// client_credentialを無効にする
|
||||
private suspend fun revokeClientCredential(
|
||||
clientInfo: JsonObject,
|
||||
clientCredential: String,
|
||||
): TootApiResult? {
|
||||
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
|
||||
if (result.error != null) return result
|
||||
|
||||
val clientId = clientInfo.string("client_id")
|
||||
?: return result.setError("missing client_id")
|
||||
|
||||
val clientSecret = clientInfo.string("client_secret")
|
||||
?: return result.setError("missing client_secret")
|
||||
|
||||
if (!sendRequest(result) {
|
||||
"token=${
|
||||
clientCredential.encodePercent()
|
||||
}&client_id=${
|
||||
clientId.encodePercent()
|
||||
}&client_secret=${
|
||||
clientSecret.encodePercent()
|
||||
}"
|
||||
.toFormRequestBody().toPost()
|
||||
.url("https://${apiHost?.ascii}/oauth/revoke")
|
||||
.build()
|
||||
}) return result
|
||||
|
||||
return parseJson(result)
|
||||
}
|
||||
|
||||
// 認証ページURLを作る
|
||||
internal fun prepareBrowserUrl(scopeString: String, clientInfo: JsonObject): String? {
|
||||
val account = this.account
|
||||
val clientId = clientInfo.string("client_id") ?: return null
|
||||
|
||||
val state = StringBuilder()
|
||||
.append((if (account != null) "db:${account.db_id}" else "host:${apiHost?.ascii}"))
|
||||
.append(',')
|
||||
.append("random:${System.currentTimeMillis()}")
|
||||
.toString()
|
||||
|
||||
return "https://${
|
||||
apiHost?.ascii
|
||||
}/oauth/authorize?client_id=${
|
||||
clientId.encodePercent()
|
||||
}&response_type=code&redirect_uri=${
|
||||
REDIRECT_URL.encodePercent()
|
||||
}&scope=$scopeString&scopes=$scopeString&state=${
|
||||
state.encodePercent()
|
||||
}&grant_type=authorization_code&approval_prompt=force&force_login=true"
|
||||
// +"&access_type=offline"
|
||||
}
|
||||
|
||||
private suspend fun prepareClientMastodon(
|
||||
clientNameArg: String,
|
||||
ti: TootInstance?,
|
||||
/**
|
||||
* クライアントを登録してブラウザで開くURLを生成する
|
||||
* 成功したら TootApiResult.data にURL文字列を格納すること
|
||||
*/
|
||||
suspend fun authStep1(
|
||||
forceUpdateClient: Boolean = false,
|
||||
): TootApiResult? {
|
||||
// 前準備
|
||||
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
|
||||
if (result.error != null) return result
|
||||
val instance = result.caption // same to instance
|
||||
|
||||
// クライアントIDがアプリ上に保存されているか?
|
||||
val clientName = clientNameArg.notEmpty() ?: DEFAULT_CLIENT_NAME
|
||||
var clientInfo = ClientInfo.load(instance, clientName)
|
||||
|
||||
// スコープ一覧を取得する
|
||||
val scopeString = getScopeString(ti)
|
||||
|
||||
when {
|
||||
AUTH_VERSION != clientInfo?.int(KEY_AUTH_VERSION) -> {
|
||||
// 古いクライアント情報は使わない。削除もしない。
|
||||
}
|
||||
|
||||
clientInfo.boolean(KEY_IS_MISSKEY) == true -> {
|
||||
// Misskeyにはclient情報をまだ利用できるかどうか調べる手段がないので、再利用しない
|
||||
}
|
||||
|
||||
else -> {
|
||||
val oldScope = clientInfo.string(KEY_CLIENT_SCOPE)
|
||||
|
||||
// client_credential をまだ取得していないなら取得する
|
||||
var clientCredential = clientInfo.string(KEY_CLIENT_CREDENTIAL)
|
||||
if (clientCredential?.isEmpty() != false) {
|
||||
val resultSub = getClientCredential(clientInfo)
|
||||
clientCredential = resultSub?.string
|
||||
if (clientCredential?.isNotEmpty() == true) {
|
||||
try {
|
||||
clientInfo[KEY_CLIENT_CREDENTIAL] = clientCredential
|
||||
ClientInfo.save(instance, clientName, clientInfo.toString())
|
||||
} catch (ignored: JsonException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// client_credential があるならcredentialがまだ使えるか確認する
|
||||
if (clientCredential?.isNotEmpty() == true) {
|
||||
val resultSub = verifyClientCredential(clientCredential)
|
||||
val currentCC = resultSub?.jsonObject
|
||||
if (currentCC != null) {
|
||||
if (oldScope != scopeString || forceUpdateClient) {
|
||||
// マストドン2.4でスコープが追加された
|
||||
// 取得時のスコープ指定がマッチしない(もしくは記録されていない)ならクライアント情報を再利用してはいけない
|
||||
ClientInfo.delete(instance, clientName)
|
||||
|
||||
// client credential をタンスから消去する
|
||||
revokeClientCredential(clientInfo, clientCredential)
|
||||
|
||||
// XXX クライアントアプリ情報そのものはまだサーバに残っているが、明示的に消す方法は現状存在しない
|
||||
} else {
|
||||
// クライアント情報を再利用する
|
||||
result.data = clientInfo
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val r2 = registerClient(scopeString, clientName)
|
||||
clientInfo = r2?.jsonObject ?: return r2
|
||||
|
||||
// {"id":999,"redirect_uri":"urn:ietf:wg:oauth:2.0:oob","client_id":"******","client_secret":"******"}
|
||||
clientInfo[KEY_AUTH_VERSION] = AUTH_VERSION
|
||||
clientInfo[KEY_CLIENT_SCOPE] = scopeString
|
||||
|
||||
// client_credential をまだ取得していないなら取得する
|
||||
var clientCredential = clientInfo.string(KEY_CLIENT_CREDENTIAL)
|
||||
if (clientCredential?.isEmpty() != false) {
|
||||
getClientCredential(clientInfo).let { resultSub ->
|
||||
when {
|
||||
// https://github.com/tateisu/SubwayTooter/issues/156
|
||||
// some servers not support to get client_credentials.
|
||||
// just ignore error and skip.
|
||||
resultSub?.response?.code == 422 -> {
|
||||
}
|
||||
resultSub == null || resultSub.error != null -> {
|
||||
return resultSub
|
||||
}
|
||||
else -> {
|
||||
resultSub.string?.notEmpty()?.let {
|
||||
clientCredential = it
|
||||
clientInfo[KEY_CLIENT_CREDENTIAL] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
ClientInfo.save(instance, clientName, clientInfo.toString())
|
||||
} catch (ignored: JsonException) {
|
||||
}
|
||||
result.data = clientInfo
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun authentication1Mastodon(
|
||||
clientNameArg: String,
|
||||
ti: TootInstance?,
|
||||
forceUpdateClient: Boolean = false,
|
||||
): TootApiResult? {
|
||||
|
||||
if (ti?.instanceType == InstanceType.Pixelfed) {
|
||||
return TootApiResult("currently Pixelfed instance is not supported.")
|
||||
}
|
||||
|
||||
return prepareClientMastodon(clientNameArg, ti, forceUpdateClient)?.also { result ->
|
||||
val clientInfo = result.jsonObject
|
||||
if (clientInfo != null) {
|
||||
result.data = prepareBrowserUrl(getScopeString(ti), clientInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// クライアントを登録してブラウザで開くURLを生成する
|
||||
suspend fun authentication1(
|
||||
clientNameArg: String,
|
||||
forceUpdateClient: Boolean = false,
|
||||
): TootApiResult? {
|
||||
|
||||
): Uri {
|
||||
val (ti, ri) = TootInstance.get(this)
|
||||
log.i("authentication1: instance info version=${ti?.version} misskeyVersion=${ti?.misskeyVersion} responseCode=${ri?.response?.code}")
|
||||
return if (ti == null) when (ri?.response?.code) {
|
||||
// https://github.com/tateisu/SubwayTooter/issues/155
|
||||
// Mastodon's WHITELIST_MODE
|
||||
401 -> authentication1Mastodon(clientNameArg, null, forceUpdateClient)
|
||||
else -> ri
|
||||
} else when {
|
||||
ti.misskeyVersion > 0 -> authentication1Misskey(clientNameArg, ti)
|
||||
else -> authentication1Mastodon(clientNameArg, ti, forceUpdateClient)
|
||||
log.i("authentication1: instance info version=${ti?.version} misskeyVersion=${ti?.misskeyVersionMajor} responseCode=${ri?.response?.code}")
|
||||
return when (val auth = AuthBase.findAuth(this, ti, ri)) {
|
||||
null -> error("can't get server information. ${ri?.error}")
|
||||
else -> auth.authStep1(ti, forceUpdateClient)
|
||||
}
|
||||
}
|
||||
|
||||
// oAuth2認証の続きを行う
|
||||
suspend fun authentication2Mastodon(
|
||||
clientNameArg: String,
|
||||
code: String,
|
||||
outAccessToken: AtomicReference<String>,
|
||||
): TootApiResult? {
|
||||
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
|
||||
if (result.error != null) return result
|
||||
|
||||
val instance = result.caption // same to instance
|
||||
val clientName = clientNameArg.ifEmpty { DEFAULT_CLIENT_NAME }
|
||||
val clientInfo =
|
||||
ClientInfo.load(instance, clientName) ?: return result.setError("missing client id")
|
||||
|
||||
if (!sendRequest(result) {
|
||||
|
||||
val scopeString = clientInfo.string(KEY_CLIENT_SCOPE)
|
||||
val clientId = clientInfo.string("client_id")
|
||||
val clientSecret = clientInfo.string("client_secret")
|
||||
if (clientId == null) return result.setError("missing client_id ")
|
||||
if (clientSecret == null) return result.setError("missing client_secret")
|
||||
|
||||
val postContent = "grant_type=authorization_code&code=${
|
||||
code.encodePercent()
|
||||
}&client_id=${
|
||||
clientId.encodePercent()
|
||||
}&redirect_uri=${
|
||||
REDIRECT_URL.encodePercent()
|
||||
}&client_secret=${
|
||||
clientSecret.encodePercent()
|
||||
}&scope=$scopeString&scopes=$scopeString"
|
||||
|
||||
postContent.toFormRequestBody().toPost()
|
||||
.url("https://$instance/oauth/token")
|
||||
.build()
|
||||
}) return result
|
||||
|
||||
val r2 = parseJson(result)
|
||||
val tokenInfo = r2?.jsonObject ?: return r2
|
||||
|
||||
// {"access_token":"******","token_type":"bearer","scope":"read","created_at":1492334641}
|
||||
val accessToken = tokenInfo.string("access_token")
|
||||
if (accessToken?.isEmpty() != false) {
|
||||
return result.setError("missing access_token in the response.")
|
||||
}
|
||||
outAccessToken.set(accessToken)
|
||||
return getUserCredential(accessToken, tokenInfo)
|
||||
}
|
||||
|
||||
// アクセストークン手動入力でアカウントを更新する場合、アカウントの情報を取得する
|
||||
suspend fun getUserCredential(
|
||||
accessToken: String,
|
||||
tokenInfo: JsonObject = JsonObject(),
|
||||
outTokenInfo: JsonObject?,
|
||||
misskeyVersion: Int = 0,
|
||||
): TootApiResult? {
|
||||
if (misskeyVersion > 0) {
|
||||
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
|
||||
if (result.error != null) return result
|
||||
|
||||
// 認証されたアカウントのユーザ情報を取得する
|
||||
if (!sendRequest(result) {
|
||||
JsonObject().apply {
|
||||
put("i", accessToken)
|
||||
}
|
||||
.toPostRequestBuilder()
|
||||
.url("https://${apiHost?.ascii}/api/i")
|
||||
.build()
|
||||
}) return result
|
||||
|
||||
val r2 = parseJson(result)
|
||||
if (r2?.jsonObject != null) {
|
||||
// ユーザ情報を読めたならtokenInfoを保存する
|
||||
tokenInfo[KEY_AUTH_VERSION] = AUTH_VERSION
|
||||
tokenInfo[KEY_API_KEY_MISSKEY] = accessToken
|
||||
tokenInfo[KEY_MISSKEY_VERSION] = misskeyVersion
|
||||
result.tokenInfo = tokenInfo
|
||||
}
|
||||
return r2
|
||||
} else {
|
||||
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
|
||||
if (result.error != null) return result
|
||||
|
||||
// 認証されたアカウントのユーザ情報を取得する
|
||||
if (!sendRequest(result) {
|
||||
Request.Builder()
|
||||
.url("https://${apiHost?.ascii}/api/v1/accounts/verify_credentials")
|
||||
.header("Authorization", "Bearer $accessToken")
|
||||
.build()
|
||||
}) return result
|
||||
|
||||
val r2 = parseJson(result)
|
||||
if (r2?.jsonObject != null) {
|
||||
// ユーザ情報を読めたならtokenInfoを保存する
|
||||
tokenInfo[KEY_AUTH_VERSION] = AUTH_VERSION
|
||||
tokenInfo["access_token"] = accessToken
|
||||
result.tokenInfo = tokenInfo
|
||||
}
|
||||
return r2
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createUser1(clientNameArg: String): TootApiResult? {
|
||||
) = AuthBase.findAuthForUserCredentian(this, misskeyVersion)
|
||||
.verifyAccount(accessToken, outTokenInfo, misskeyVersion)
|
||||
|
||||
/**
|
||||
* サーバにクライアントアプリを登録する
|
||||
* - Mastodonのユーザ作成で呼ばれる
|
||||
*
|
||||
* @return clientInfo
|
||||
*/
|
||||
suspend fun prepareClient(): JsonObject {
|
||||
val (ti, ri) = TootInstance.get(this)
|
||||
ti ?: return ri
|
||||
|
||||
return when (ti.instanceType) {
|
||||
InstanceType.Misskey ->
|
||||
TootApiResult("Misskey has no API to create new account")
|
||||
InstanceType.Pleroma ->
|
||||
TootApiResult("Pleroma has no API to create new account")
|
||||
InstanceType.Pixelfed ->
|
||||
TootApiResult("Pixelfed has no API to create new account")
|
||||
else ->
|
||||
prepareClientMastodon(clientNameArg, ti)
|
||||
// result.JsonObject に credentialつきのclient_info を格納して返す
|
||||
ti ?: error("${ri?.error}")
|
||||
return when (val auth = AuthBase.findAuthForCreateUser(this, ti)) {
|
||||
null -> error("user creation not supported for server type [${ti.instanceType}]")
|
||||
else -> auth.prepareClient(ti)
|
||||
}
|
||||
}
|
||||
|
||||
// ユーザ名入力の後に呼ばれる
|
||||
suspend fun createUser2Mastodon(
|
||||
clientInfo: JsonObject,
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
agreement: Boolean,
|
||||
reason: String?,
|
||||
): TootApiResult? {
|
||||
|
||||
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
|
||||
if (result.error != null) return result
|
||||
|
||||
log.d("createUser2Mastodon: client is : $clientInfo")
|
||||
|
||||
val clientCredential = clientInfo.string(KEY_CLIENT_CREDENTIAL)
|
||||
?: return result.setError("createUser2Mastodon(): missing client credential")
|
||||
|
||||
if (!sendRequest(result) {
|
||||
|
||||
val params = ArrayList<String>().apply {
|
||||
add("username=${username.encodePercent()}")
|
||||
add("email=${email.encodePercent()}")
|
||||
add("password=${password.encodePercent()}")
|
||||
add("agreement=$agreement")
|
||||
if (reason?.isNotEmpty() == true) add("reason=${reason.encodePercent()}")
|
||||
}
|
||||
|
||||
params
|
||||
.joinToString("&").toFormRequestBody().toPost()
|
||||
.url("https://${apiHost?.ascii}/api/v1/accounts")
|
||||
.header("Authorization", "Bearer $clientCredential")
|
||||
.build()
|
||||
}) return result
|
||||
|
||||
return parseJson(result)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// JSONデータ以外を扱うリクエスト
|
||||
|
||||
|
@ -1238,7 +502,7 @@ class TootApiClient(
|
|||
wsListener: WebSocketListener,
|
||||
): Pair<TootApiResult?, WebSocket?> {
|
||||
var ws: WebSocket? = null
|
||||
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
|
||||
val result = TootApiResult.makeWithCaption(apiHost)
|
||||
if (result.error != null) return Pair(result, null)
|
||||
val account = this.account ?: return Pair(TootApiResult("account is null"), null)
|
||||
try {
|
||||
|
|
|
@ -0,0 +1,356 @@
|
|||
package jp.juggler.subwaytooter.api
|
||||
|
||||
import android.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import jp.juggler.subwaytooter.R
|
||||
import jp.juggler.subwaytooter.action.isAndroid7TlsBug
|
||||
import jp.juggler.subwaytooter.util.DecodeOptions
|
||||
import jp.juggler.subwaytooter.util.ProgressResponseBody
|
||||
import jp.juggler.util.coroutine.AppDispatchers
|
||||
import jp.juggler.util.data.*
|
||||
import jp.juggler.util.log.LogCategory
|
||||
import jp.juggler.util.log.showToast
|
||||
import jp.juggler.util.log.withCaption
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.closeQuietly
|
||||
import java.io.IOException
|
||||
|
||||
private val log = LogCategory("TootApiClientExt")
|
||||
|
||||
class SendException(
|
||||
val request: Request,
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
val response: Response? = null,
|
||||
) : IOException(message, cause)
|
||||
|
||||
abstract class ResponseWithBase {
|
||||
abstract val client: TootApiClient
|
||||
abstract val response: Response
|
||||
abstract val progressPath: String?
|
||||
abstract val errorSuffix: String?
|
||||
abstract val jsonErrorParser: (json: JsonObject) -> String?
|
||||
|
||||
/**
|
||||
* 応答ボディのHTMLやテキストを整形する
|
||||
*/
|
||||
private fun simplifyErrorHtml(body: String): String {
|
||||
// JsonObjectとして解釈できるならエラーメッセージを検出する
|
||||
try {
|
||||
val json = body.decodeJsonObject()
|
||||
jsonErrorParser(json)?.notEmpty()?.let { return it }
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
|
||||
// HTMLならタグの除去を試みる
|
||||
try {
|
||||
val ct = response.body?.contentType()
|
||||
if (ct?.subtype == "html") {
|
||||
val decoded = DecodeOptions().decodeHTML(body).toString()
|
||||
return TootApiResult.reWhiteSpace.matcher(decoded).replaceAll(" ").trim()
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
|
||||
// XXX: Amazon S3 が403を返した場合にcontent-typeが?/xmlでserverがAmazonならXMLをパースしてエラーを整形することもできるが、多分必要ない
|
||||
|
||||
// 通常テキストの空白や改行を整理した文字列を返す
|
||||
try {
|
||||
return TootApiResult.reWhiteSpace.matcher(body).replaceAll(" ").trim()
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
|
||||
// 全部失敗したら入力そのまま
|
||||
return body
|
||||
}
|
||||
|
||||
/**
|
||||
* エラー応答のステータス部分や本文を文字列にする
|
||||
*/
|
||||
fun parseErrorResponse(body: String? = null): String =
|
||||
try {
|
||||
StringBuilder().apply {
|
||||
// 応答ボディのテキストがあれば追加
|
||||
if (body.isNullOrBlank()) {
|
||||
append("(missing response body)")
|
||||
} else {
|
||||
append(simplifyErrorHtml(body))
|
||||
}
|
||||
if (isNotEmpty()) append(' ')
|
||||
append("(HTTP ").append(response.code.toString())
|
||||
response.message.notBlank()?.let { message ->
|
||||
append(' ')
|
||||
append(message)
|
||||
}
|
||||
append(")")
|
||||
errorSuffix?.notBlank()?.let {
|
||||
append(' ')
|
||||
append(errorSuffix)
|
||||
}
|
||||
}.toString().replace("""[\x0d\x0a]+""".toRegex(), "\n")
|
||||
} catch (ex: Throwable) {
|
||||
log.e(ex, "parseErrorResponse failed.")
|
||||
"(can't parse response body)"
|
||||
}
|
||||
|
||||
fun <T : Any?> newContent(newContent: T) = ResponseWith(
|
||||
client = client,
|
||||
response = response,
|
||||
progressPath = progressPath,
|
||||
errorSuffix = errorSuffix,
|
||||
jsonErrorParser = jsonErrorParser,
|
||||
content = newContent
|
||||
)
|
||||
}
|
||||
|
||||
class ResponseWith<T : Any?>(
|
||||
override val client: TootApiClient,
|
||||
override val response: Response,
|
||||
override val progressPath: String? = null,
|
||||
override val errorSuffix: String? = null,
|
||||
override val jsonErrorParser: (json: JsonObject) -> String? = TootApiClient.DEFAULT_JSON_ERROR_PARSER,
|
||||
val content: T,
|
||||
) : ResponseWithBase()
|
||||
|
||||
class ResponseBeforeRead(
|
||||
override val client: TootApiClient,
|
||||
override val response: Response,
|
||||
override val progressPath: String? = null,
|
||||
override val errorSuffix: String? = null,
|
||||
override val jsonErrorParser: (json: JsonObject) -> String? = TootApiClient.DEFAULT_JSON_ERROR_PARSER,
|
||||
) : ResponseWithBase() {
|
||||
/**
|
||||
* レスポンスボディを文字列として読む
|
||||
* ボディがない場合はnullを返す
|
||||
* その他はSendExceptionを返す
|
||||
*/
|
||||
private suspend fun readString(): ResponseWith<String?> {
|
||||
val response = response
|
||||
val request = response.request
|
||||
return try {
|
||||
client.publishApiProgress(
|
||||
client.context.getString(
|
||||
R.string.reading_api,
|
||||
request.method,
|
||||
progressPath ?: request.url.host
|
||||
)
|
||||
)
|
||||
withContext(AppDispatchers.IO) {
|
||||
val bodyString = response.body?.string()
|
||||
if (bodyString.isNullOrEmpty()) {
|
||||
if (response.code in 200 until 300) {
|
||||
// Misskey の /api/notes/favorites/create は 204(no content)を返す。ボディはカラになる。
|
||||
return@withContext newContent("")
|
||||
} else if (!response.isSuccessful) {
|
||||
throw SendException(
|
||||
response = response,
|
||||
request = request,
|
||||
message = parseErrorResponse(body = bodyString),
|
||||
)
|
||||
}
|
||||
}
|
||||
newContent(bodyString)
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
when (ex) {
|
||||
is CancellationException, is SendException -> throw ex
|
||||
else -> {
|
||||
log.e(ex, "readString failed.")
|
||||
throw SendException(
|
||||
response = response,
|
||||
request = request,
|
||||
message = parseErrorResponse(ex.withCaption("readString failed."))
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
response.body?.closeQuietly()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* レスポンスボディを文字列として読む
|
||||
* ボディがない場合はnullを返す
|
||||
* その他はSendExceptionを返す
|
||||
*/
|
||||
suspend fun readBytes(
|
||||
callback: (suspend (bytesRead: Long, bytesTotal: Long) -> Unit)? = null,
|
||||
): ByteArray {
|
||||
val response = response
|
||||
val request = response.request
|
||||
return try {
|
||||
client.publishApiProgress(
|
||||
client.context.getString(
|
||||
R.string.reading_api,
|
||||
request.method,
|
||||
request.url
|
||||
)
|
||||
)
|
||||
withContext(AppDispatchers.IO) {
|
||||
when {
|
||||
!response.isSuccessful -> {
|
||||
val errorBody = try {
|
||||
response.body?.string()
|
||||
} catch (ignored: Throwable) {
|
||||
null
|
||||
}
|
||||
throw SendException(
|
||||
response = response,
|
||||
request = request,
|
||||
message = parseErrorResponse(body = errorBody),
|
||||
)
|
||||
}
|
||||
callback != null ->
|
||||
ProgressResponseBody.bytes(response, callback)
|
||||
|
||||
else ->
|
||||
response.body?.bytes() ?: error("missing response body.")
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
when (ex) {
|
||||
is CancellationException, is SendException -> throw ex
|
||||
else -> {
|
||||
log.e(ex, "readString failed.")
|
||||
throw SendException(
|
||||
response = response,
|
||||
request = request,
|
||||
message = parseErrorResponse(ex.withCaption("readString failed."))
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
response.body?.closeQuietly()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun readJsonObject() = readString().stringToJsonObject()
|
||||
}
|
||||
|
||||
/**
|
||||
* okHttpのリクエストを TootApiClient で処理して Response を得る
|
||||
* 失敗すると SendException を投げる
|
||||
*/
|
||||
suspend fun Request.send(
|
||||
client: TootApiClient,
|
||||
progressPath: String? = null,
|
||||
errorSuffix: String = "",
|
||||
overrideClient: OkHttpClient? = null,
|
||||
jsonErrorParser: (json: JsonObject) -> String? = TootApiClient.DEFAULT_JSON_ERROR_PARSER,
|
||||
): ResponseBeforeRead {
|
||||
val request = this
|
||||
val requestInfo = "$method ${progressPath ?: url.encodedPath}"
|
||||
client.context.getString(R.string.request_api, method, progressPath ?: url.encodedPath)
|
||||
.let { client.callback.publishApiProgress(it) }
|
||||
return try {
|
||||
ResponseBeforeRead(
|
||||
client = client,
|
||||
response = client.httpClient.getResponse(request, overrideClient = overrideClient),
|
||||
jsonErrorParser = jsonErrorParser,
|
||||
progressPath = progressPath,
|
||||
errorSuffix = errorSuffix,
|
||||
)
|
||||
} catch (ex: Throwable) {
|
||||
// キャンセルはそのまま投げる
|
||||
if (ex is CancellationException) throw ex
|
||||
// 他は SendException に加工する
|
||||
val error = ex.withCaption(client.context.resources, R.string.network_error)
|
||||
throw SendException(
|
||||
cause = ex,
|
||||
message = when (errorSuffix) {
|
||||
"" -> "$error $requestInfo$method ${url.host} ${progressPath ?: url.encodedPath}"
|
||||
else -> "$error $requestInfo$method ${url.host} ${progressPath ?: url.encodedPath} ($errorSuffix)"
|
||||
},
|
||||
request = request,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ResponseWith<String?> をResponseWith<JsonObject?>に変換する
|
||||
*/
|
||||
suspend fun ResponseWith<String?>.stringToJsonObject(): JsonObject =
|
||||
try {
|
||||
withContext(AppDispatchers.IO) {
|
||||
when {
|
||||
content == null -> throw SendException(
|
||||
request = response.request,
|
||||
response = response,
|
||||
message = "response body is null. ($errorSuffix)",
|
||||
)
|
||||
|
||||
// 204 no content は 空オブジェクトと解釈する
|
||||
content == "" -> JsonObject()
|
||||
|
||||
TootApiClient.reStartJsonArray.find(content) != null ->
|
||||
jsonObjectOf("root" to content.decodeJsonArray())
|
||||
|
||||
TootApiClient.reStartJsonObject.find(content) != null -> {
|
||||
val json = content.decodeJsonObject()
|
||||
jsonErrorParser(json)?.let {
|
||||
throw SendException(
|
||||
request = response.request,
|
||||
response = response,
|
||||
message = "$it ($errorSuffix)",
|
||||
)
|
||||
}
|
||||
json
|
||||
}
|
||||
|
||||
else -> throw SendException(
|
||||
response = response,
|
||||
request = response.request,
|
||||
message = parseErrorResponse("not a JSON object.")
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
when (ex) {
|
||||
is CancellationException, is SendException -> throw ex
|
||||
else -> {
|
||||
log.e(ex, "readJsonObject failed. ($errorSuffix)")
|
||||
throw SendException(
|
||||
response = response,
|
||||
request = response.request,
|
||||
message = ex.withCaption("readJsonObject failed. ($errorSuffix)"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun AppCompatActivity.dialogOrToast(message: String?) {
|
||||
if (message.isNullOrBlank()) return
|
||||
try {
|
||||
AlertDialog.Builder(this)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(R.string.close, null)
|
||||
.show()
|
||||
} catch (_: Throwable) {
|
||||
showToast(true, message)
|
||||
}
|
||||
}
|
||||
|
||||
fun AppCompatActivity.showApiError(ex: Throwable) {
|
||||
try {
|
||||
log.e(ex, "showApiError")
|
||||
val errorText = ex.message
|
||||
if (isAndroid7TlsBug(errorText ?: "")) {
|
||||
dialogOrToast(errorText + "\n\n" + getString(R.string.ssl_bug_7_0))
|
||||
return
|
||||
}
|
||||
when (ex) {
|
||||
is CancellationException -> return
|
||||
is SendException -> dialogOrToast("${ex.message} ${ex.request.method} ${ex.request.url}")
|
||||
is IllegalStateException -> when (ex.cause) {
|
||||
null -> dialogOrToast(ex.message ?: "(??)")
|
||||
else -> dialogOrToast(ex.withCaption())
|
||||
}
|
||||
else -> dialogOrToast(ex.withCaption())
|
||||
}
|
||||
} catch (ignored: Throwable) {
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package jp.juggler.subwaytooter.api
|
||||
|
||||
import jp.juggler.subwaytooter.api.entity.Host
|
||||
import jp.juggler.subwaytooter.util.DecodeOptions
|
||||
import jp.juggler.util.*
|
||||
import jp.juggler.util.data.*
|
||||
|
@ -19,19 +20,20 @@ open class TootApiResult(
|
|||
|
||||
private val log = LogCategory("TootApiResult")
|
||||
|
||||
private val reWhiteSpace = """\s+""".asciiPattern()
|
||||
val reWhiteSpace = """\s+""".asciiPattern()
|
||||
|
||||
private val reLinkURL = """<([^>]+)>;\s*rel="([^"]+)"""".asciiPattern()
|
||||
|
||||
fun makeWithCaption(caption: String?): TootApiResult {
|
||||
val result = TootApiResult()
|
||||
if (caption?.isEmpty() != false) {
|
||||
log.e("makeWithCaption: missing caption!")
|
||||
result.error = "missing instance name"
|
||||
} else {
|
||||
result.caption = caption
|
||||
fun makeWithCaption(apiHost: Host?) = makeWithCaption(apiHost?.pretty)
|
||||
|
||||
fun makeWithCaption(caption: String?) = TootApiResult().apply {
|
||||
when (caption) {
|
||||
null, "" -> {
|
||||
log.e("makeWithCaption: missing caption!")
|
||||
error = "missing instance name"
|
||||
}
|
||||
else -> this.caption = caption
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -79,10 +81,7 @@ open class TootApiResult(
|
|||
}
|
||||
|
||||
// return result.setError(...) と書きたい
|
||||
fun setError(error: String): TootApiResult {
|
||||
this.error = error
|
||||
return this
|
||||
}
|
||||
fun setError(error: String) = also{ it.error = error}
|
||||
|
||||
private fun parseLinkHeader(response: Response?, array: JsonArray) {
|
||||
response ?: return
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
package jp.juggler.subwaytooter.api.auth
|
||||
|
||||
import jp.juggler.subwaytooter.api.entity.TootAccount
|
||||
import jp.juggler.subwaytooter.api.entity.TootInstance
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.util.data.JsonObject
|
||||
|
||||
/**
|
||||
* ブラウザで認証してコールバックURLで戻ってきて、
|
||||
* そのURLを使って認証した結果
|
||||
*/
|
||||
class Auth2Result(
|
||||
// サーバ情報
|
||||
val tootInstance: TootInstance,
|
||||
|
||||
// TootAccountユーザ情報の元となるJSONデータ
|
||||
val accountJson: JsonObject,
|
||||
|
||||
// AccountJsonのパース結果
|
||||
val tootAccount: TootAccount,
|
||||
|
||||
// アクセストークンを含むJsonObject
|
||||
val tokenJson: JsonObject,
|
||||
) {
|
||||
// 対象サーバのAPIホスト
|
||||
val apiHost get() = tootInstance.apiHost
|
||||
|
||||
// サーバ情報から取得したActivityPubドメイン
|
||||
val apDomain get() = tootInstance.apDomain
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
package jp.juggler.subwaytooter.api.auth
|
||||
|
||||
import android.net.Uri
|
||||
import jp.juggler.subwaytooter.api.TootApiClient
|
||||
import jp.juggler.subwaytooter.api.TootApiResult
|
||||
import jp.juggler.subwaytooter.api.entity.InstanceType
|
||||
import jp.juggler.subwaytooter.api.entity.TootInstance
|
||||
import jp.juggler.subwaytooter.pref.PrefS
|
||||
import jp.juggler.util.data.JsonObject
|
||||
import jp.juggler.util.data.notBlank
|
||||
|
||||
abstract class AuthBase {
|
||||
companion object {
|
||||
private const val DEFAULT_CLIENT_NAME = "SubwayTooter"
|
||||
const val appNameId = "SubwayTooter"
|
||||
const val appDescription = "Android app for federated SNS"
|
||||
|
||||
// 20181225 3=>4 client credentialの取得時にもscopeの取得が必要になった
|
||||
// 20190147 4=>5 client id とユーザIDが同じだと同じアクセストークンが返ってくるので複数端末の利用で困る。
|
||||
// AUTH_VERSIONが古いclient情報は使わない。また、インポートの対象にしない。
|
||||
const val AUTH_VERSION = 5
|
||||
|
||||
const val KEY_CLIENT_CREDENTIAL = "SubwayTooterClientCredential"
|
||||
const val KEY_CLIENT_SCOPE = "SubwayTooterClientScope"
|
||||
const val KEY_AUTH_VERSION = "SubwayTooterAuthVersion"
|
||||
const val KEY_IS_MISSKEY = "isMisskey" // for ClientInfo
|
||||
const val KEY_MISSKEY_VERSION = "isMisskey" // for tokenInfo,TootInstance
|
||||
const val KEY_MISSKEY_APP_SECRET = "secret"
|
||||
const val KEY_API_KEY_MISSKEY = "apiKeyMisskey"
|
||||
const val KEY_USER_ID = "userId"
|
||||
|
||||
var testClientName: String? = null
|
||||
|
||||
val clientName
|
||||
get() = arrayOf(
|
||||
testClientName,
|
||||
PrefS.spClientName.invoke(),
|
||||
).firstNotNullOfOrNull { it.notBlank() }
|
||||
?: DEFAULT_CLIENT_NAME
|
||||
|
||||
fun findAuth(client: TootApiClient, ti: TootInstance?, ri: TootApiResult?): AuthBase? =
|
||||
when {
|
||||
// インスタンス情報を取得できない
|
||||
ti == null -> when (ri?.response?.code) {
|
||||
// https://github.com/tateisu/SubwayTooter/issues/155
|
||||
// Mastodon's WHITELIST_MODE
|
||||
401 -> MastodonAuth(client)
|
||||
else -> null
|
||||
}
|
||||
ti.isMisskey -> when {
|
||||
ti.versionGE(TootInstance.MISSKEY_VERSION_13) ->
|
||||
MisskeyAuth13(client)
|
||||
else ->
|
||||
MisskeyAuth10(client)
|
||||
}
|
||||
else -> MastodonAuth(client)
|
||||
}
|
||||
|
||||
fun findAuthForAuthCallback(client: TootApiClient, callbackUrl: String): AuthBase =
|
||||
when {
|
||||
MisskeyAuth10.isCallbackUrl(callbackUrl) -> MisskeyAuth10(client)
|
||||
MisskeyAuth13.isCallbackUrl(callbackUrl) -> MisskeyAuth13(client)
|
||||
else -> MastodonAuth(client)
|
||||
}
|
||||
|
||||
fun findAuthForUserCredentian(client: TootApiClient, misskeyVersion: Int) =
|
||||
when {
|
||||
misskeyVersion >= 13 -> MisskeyAuth13(client)
|
||||
misskeyVersion > 0 -> MisskeyAuth10(client)
|
||||
else -> MastodonAuth(client)
|
||||
}
|
||||
|
||||
fun findAuthForCreateUser(client: TootApiClient, ti: TootInstance) =
|
||||
when (ti.instanceType) {
|
||||
InstanceType.Misskey -> null
|
||||
InstanceType.Pleroma -> null
|
||||
InstanceType.Pixelfed -> null
|
||||
else -> MastodonAuth(client)
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract val client: TootApiClient
|
||||
|
||||
protected val apiHost get() = client.apiHost
|
||||
protected val account get() = client.account
|
||||
protected val context get() = client.context
|
||||
|
||||
|
||||
/**
|
||||
* クライアントを登録してブラウザで開くURLを生成する
|
||||
* 成功したら TootApiResult.data にURL文字列を格納すること
|
||||
*/
|
||||
abstract suspend fun authStep1(
|
||||
// サーバ情報。Mastodonのホワイトリストモードではnullかもしれない
|
||||
ti: TootInstance?,
|
||||
// (Mastodon)クライアントを強制的に登録しなおす
|
||||
forceUpdateClient: Boolean,
|
||||
): Uri
|
||||
|
||||
/**
|
||||
* ブラウザから戻ってきたコールバックURLを使い認証の続きを行う
|
||||
*/
|
||||
abstract suspend fun authStep2(uri: Uri): Auth2Result
|
||||
|
||||
/**
|
||||
* アクセストークンを手動入力した場合、それを使って本人のユーザ情報を取得する
|
||||
*/
|
||||
abstract suspend fun verifyAccount(
|
||||
accessToken: String,
|
||||
outTokenJson: JsonObject?,
|
||||
misskeyVersion: Int,
|
||||
): JsonObject
|
||||
}
|
|
@ -0,0 +1,347 @@
|
|||
package jp.juggler.subwaytooter.api.auth
|
||||
|
||||
import android.net.Uri
|
||||
import jp.juggler.subwaytooter.api.SendException
|
||||
import jp.juggler.subwaytooter.api.TootApiClient
|
||||
import jp.juggler.subwaytooter.api.TootParser
|
||||
import jp.juggler.subwaytooter.api.entity.Host
|
||||
import jp.juggler.subwaytooter.api.entity.InstanceType
|
||||
import jp.juggler.subwaytooter.api.entity.TootInstance
|
||||
import jp.juggler.subwaytooter.table.ClientInfo
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.subwaytooter.util.LinkHelper
|
||||
import jp.juggler.util.data.JsonObject
|
||||
import jp.juggler.util.data.notEmpty
|
||||
import jp.juggler.util.log.LogCategory
|
||||
import jp.juggler.util.log.errorEx
|
||||
|
||||
class MastodonAuth(override val client: TootApiClient) : AuthBase() {
|
||||
|
||||
companion object {
|
||||
private val log = LogCategory("MastodonAuth")
|
||||
|
||||
const val callbackUrl = "subwaytooter://oauth/"
|
||||
|
||||
fun mastodonScope(ti: TootInstance?) = when {
|
||||
// 古いサーバ
|
||||
ti?.versionGE(TootInstance.VERSION_2_4_0_rc1) == false -> "read write follow"
|
||||
|
||||
// 新しいサーバか、AUTHORIZED_FETCH(3.0.0以降)によりサーバ情報を取得できなかった
|
||||
else -> "read write follow push"
|
||||
|
||||
// 過去の試行錯誤かな
|
||||
// ti.versionGE(TootInstance.VERSION_2_7_0_rc1) -> "read+write+follow+push+create"
|
||||
}
|
||||
}
|
||||
|
||||
val api = MastodonAuthApi(client)
|
||||
|
||||
// クライアントアプリの登録を確認するためのトークンを生成する
|
||||
// oAuth2 Client Credentials の取得
|
||||
// https://github.com/doorkeeper-gem/doorkeeper/wiki/Client-Credentials-flow
|
||||
// このトークンはAPIを呼び出すたびに新しく生成される…
|
||||
private suspend fun createClientCredentialToken(
|
||||
apiHost: Host,
|
||||
savedClientInfo: JsonObject,
|
||||
): String {
|
||||
val credentialInfo = api.createClientCredential(
|
||||
apiHost = apiHost,
|
||||
clientId = savedClientInfo.string("client_id")
|
||||
?: error("missing client_id"),
|
||||
clientSecret = savedClientInfo.string("client_secret")
|
||||
?: error("missing client_secret"),
|
||||
callbackUrl = callbackUrl,
|
||||
)
|
||||
|
||||
log.d("credentialInfo: $credentialInfo")
|
||||
|
||||
return credentialInfo.string("access_token")
|
||||
?.notEmpty() ?: error("missing client credential.")
|
||||
}
|
||||
|
||||
private suspend fun prepareClientCredential(
|
||||
apiHost: Host,
|
||||
clientInfo: JsonObject,
|
||||
clientName: String,
|
||||
): String? {
|
||||
// 既にcredentialを持っているならそれを返す
|
||||
clientInfo.string(KEY_CLIENT_CREDENTIAL)
|
||||
.notEmpty()?.let { return it }
|
||||
|
||||
// token in clientCredential
|
||||
val clientCredential = try {
|
||||
createClientCredentialToken(apiHost, clientInfo)
|
||||
} catch (ex: Throwable) {
|
||||
if ((ex as? SendException)?.response?.code == 422) {
|
||||
// https://github.com/tateisu/SubwayTooter/issues/156
|
||||
// some servers not support to get client_credentials.
|
||||
// just ignore error and skip.
|
||||
return null
|
||||
} else {
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
clientInfo[KEY_CLIENT_CREDENTIAL] = clientCredential
|
||||
ClientInfo.save(apiHost, clientName, clientInfo.toString())
|
||||
return clientCredential
|
||||
}
|
||||
|
||||
// result.JsonObject に credentialつきのclient_info を格納して返す
|
||||
private suspend fun prepareClientImpl(
|
||||
apiHost: Host,
|
||||
clientName: String,
|
||||
tootInstance: TootInstance?,
|
||||
forceUpdateClient: Boolean,
|
||||
): JsonObject {
|
||||
var clientInfo = ClientInfo.load(apiHost, clientName)
|
||||
|
||||
// スコープ一覧を取得する
|
||||
val scopeString = mastodonScope(tootInstance)
|
||||
|
||||
when {
|
||||
// 古いクライアント情報は使わない。削除もしない。
|
||||
AUTH_VERSION != clientInfo?.int(KEY_AUTH_VERSION) -> Unit
|
||||
|
||||
// Misskeyにはclient情報をまだ利用できるかどうか調べる手段がないので、再利用しない
|
||||
clientInfo.boolean(KEY_IS_MISSKEY) == true -> Unit
|
||||
|
||||
else -> {
|
||||
val clientCredential = prepareClientCredential(apiHost, clientInfo, clientName)
|
||||
// client_credential があるならcredentialがまだ使えるか確認する
|
||||
if (!clientCredential.isNullOrEmpty()) {
|
||||
|
||||
// 存在確認するだけで、結果は使ってない
|
||||
api.verifyClientCredential(apiHost, clientCredential)
|
||||
|
||||
// 過去にはスコープを+で連結したものを保存していた
|
||||
val oldScope = clientInfo.string(KEY_CLIENT_SCOPE)
|
||||
?.replace("+", " ")
|
||||
|
||||
when {
|
||||
// クライアント情報を再利用する
|
||||
!forceUpdateClient && oldScope == scopeString -> return clientInfo
|
||||
|
||||
else -> try {
|
||||
// マストドン2.4でスコープが追加された
|
||||
// 取得時のスコープ指定がマッチしない(もしくは記録されていない)ならクライアント情報を再利用してはいけない
|
||||
ClientInfo.delete(apiHost, clientName)
|
||||
|
||||
// クライアントアプリ情報そのものはまだサーバに残っているが、明示的に消す方法は現状存在しない
|
||||
// client credential だけは消せる
|
||||
api.revokeClientCredential(
|
||||
apiHost = apiHost,
|
||||
clientId = clientInfo.string("client_id")
|
||||
?: error("revokeClientCredential: missing client_id"),
|
||||
clientSecret = clientInfo.string("client_secret")
|
||||
?: error("revokeClientCredential: missing client_secret"),
|
||||
clientCredential = clientCredential,
|
||||
)
|
||||
} catch (ex: Throwable) {
|
||||
// クライアント情報の削除処理はエラーが起きても無視する
|
||||
log.w(ex, "can't delete client information.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clientInfo = api.registerClient(apiHost, scopeString, clientName, callbackUrl).apply {
|
||||
// {"id":999,"redirect_uri":"urn:ietf:wg:oauth:2.0:oob","client_id":"******","client_secret":"******"}
|
||||
put(KEY_AUTH_VERSION, AUTH_VERSION)
|
||||
put(KEY_CLIENT_SCOPE, scopeString)
|
||||
}
|
||||
// client credentialを取得して保存する
|
||||
// この時点ではまだ client credential がないので、必ず更新と保存が行われる
|
||||
prepareClientCredential(apiHost, clientInfo, clientName)
|
||||
|
||||
return clientInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* アクセストークン手動入力でアカウントを更新する場合、アカウントの情報を取得する
|
||||
* auth2の後にユーザ情報を知るためにも使われる
|
||||
*
|
||||
* 副作用:ユーザ情報を取得できたらoutTokenInfoを更新する
|
||||
*/
|
||||
override suspend fun verifyAccount(
|
||||
accessToken: String,
|
||||
outTokenJson: JsonObject?,
|
||||
misskeyVersion: Int,
|
||||
): JsonObject = api.verifyAccount(
|
||||
apiHost = apiHost ?: error("verifyAccount: missing apiHost."),
|
||||
accessToken = accessToken,
|
||||
).also {
|
||||
// APIレスポンスが成功したら、そのデータとは無関係に
|
||||
// アクセストークンをtokenInfoに格納する。
|
||||
outTokenJson?.apply {
|
||||
put(KEY_AUTH_VERSION, AUTH_VERSION)
|
||||
put("access_token", accessToken)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* クライアントを登録してブラウザで開くURLを生成する
|
||||
* 成功したら TootApiResult.data にURL文字列を格納すること
|
||||
* @param ti サーバ情報。Mastodonのホワイトリストモードではnullかもしれない
|
||||
* @param forceUpdateClient (Mastodon)クライアントを強制的に登録しなおす
|
||||
*/
|
||||
override suspend fun authStep1(
|
||||
ti: TootInstance?,
|
||||
forceUpdateClient: Boolean,
|
||||
): Uri {
|
||||
if (ti?.instanceType == InstanceType.Pixelfed) {
|
||||
error("currently Pixelfed instance is not supported.")
|
||||
}
|
||||
|
||||
val apiHost = apiHost ?: error("authStep1: missing apiHost")
|
||||
|
||||
val clientJson = prepareClientImpl(
|
||||
apiHost = apiHost,
|
||||
clientName = clientName,
|
||||
ti,
|
||||
forceUpdateClient,
|
||||
)
|
||||
|
||||
val accountDbId = account?.db_id?.takeIf { it >= 0L }
|
||||
|
||||
val state = listOf(
|
||||
"random:${System.currentTimeMillis()}",
|
||||
when (accountDbId) {
|
||||
null -> "host:${apiHost.ascii}"
|
||||
else -> "db:${accountDbId}"
|
||||
}
|
||||
).joinToString(",")
|
||||
|
||||
return api.createAuthUrl(
|
||||
apiHost = apiHost,
|
||||
scopeString = mastodonScope(ti),
|
||||
callbackUrl = callbackUrl,
|
||||
clientId = clientJson.string("client_id")
|
||||
?: error("missing client_id"),
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 認証コールバックURLを受け取り、サーバにアクセスして認証を終わらせる。
|
||||
*/
|
||||
override suspend fun authStep2(uri: Uri): Auth2Result {
|
||||
// Mastodon 認証コールバック
|
||||
|
||||
// エラー時
|
||||
// subwaytooter://oauth(\d*)/
|
||||
// ?error=access_denied
|
||||
// &error_description=%E3%83%AA%E3%82%BD%E3%83%BC%E3%82%B9%E3%81%AE%E6%89%80%E6%9C%89%E8%80%85%E3%81%BE%E3%81%9F%E3%81%AF%E8%AA%8D%E8%A8%BC%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E3%81%8C%E8%A6%81%E6%B1%82%E3%82%92%E6%8B%92%E5%90%A6%E3%81%97%E3%81%BE%E3%81%97%E3%81%9F%E3%80%82
|
||||
// &state=db%3A3
|
||||
arrayOf(
|
||||
uri.getQueryParameter("error")?.trim()?.notEmpty(),
|
||||
uri.getQueryParameter("error_description")?.trim()?.notEmpty()
|
||||
).filterNotNull().joinToString(" ")
|
||||
.notEmpty()?.let { error(it) }
|
||||
|
||||
// subwaytooter://oauth(\d*)/
|
||||
// ?code=113cc036e078ac500d3d0d3ad345cd8181456ab087abc67270d40f40a4e9e3c2
|
||||
// &state=host%3Amastodon.juggler.jp
|
||||
|
||||
val code = uri.getQueryParameter("code")
|
||||
?.trim()?.notEmpty() ?: error("missing code in callback url.")
|
||||
|
||||
val cols = uri.getQueryParameter("state")
|
||||
?.trim()?.notEmpty() ?: error("missing state in callback url.")
|
||||
|
||||
for (param in cols.split(",")) {
|
||||
when {
|
||||
param.startsWith("db:") -> try {
|
||||
val dataId = param.substring(3).toLong(10)
|
||||
val sa = SavedAccount.loadAccount(context, dataId)
|
||||
?: error("missing account db_id=$dataId")
|
||||
client.account = sa
|
||||
} catch (ex: Throwable) {
|
||||
errorEx(ex, "invalide state.db in callback parameter.")
|
||||
}
|
||||
|
||||
param.startsWith("host:") -> {
|
||||
val host = Host.parse(param.substring(5))
|
||||
client.apiHost = host
|
||||
}
|
||||
// ignore other parameter
|
||||
}
|
||||
}
|
||||
|
||||
val apiHost = client.apiHost
|
||||
?: error("can't get apiHost from callback parameter.")
|
||||
|
||||
val clientInfo = ClientInfo.load(apiHost, clientName)
|
||||
?: error("can't find client info for apiHost=$apiHost, clientName=$clientName")
|
||||
|
||||
val tokenInfo = api.authStep2(
|
||||
apiHost = apiHost,
|
||||
clientId = clientInfo.string("client_id")
|
||||
?: error("handleOAuth2Callback: missing client_id"),
|
||||
clientSecret = clientInfo.string("client_secret")
|
||||
?.notEmpty() ?: error("handleOAuth2Callback: missing client_secret"),
|
||||
scopeString = clientInfo.string(KEY_CLIENT_SCOPE)
|
||||
?.notEmpty() ?: error("handleOAuth2Callback: missing scopeString"),
|
||||
callbackUrl = callbackUrl,
|
||||
code = code,
|
||||
)
|
||||
// {"access_token":"******","token_type":"bearer","scope":"read","created_at":1492334641}
|
||||
|
||||
val accessToken = tokenInfo.string("access_token")
|
||||
?.notEmpty() ?: error("can't parse access token.")
|
||||
|
||||
val accountJson = verifyAccount(
|
||||
accessToken = accessToken,
|
||||
outTokenJson = tokenInfo,
|
||||
misskeyVersion = 0
|
||||
)
|
||||
|
||||
val (ti, ri) = TootInstance.getEx(client, forceAccessToken = accessToken)
|
||||
ti ?: error("can't get server information. ${ri?.error}")
|
||||
|
||||
return Auth2Result(
|
||||
tootInstance = ti,
|
||||
accountJson = accountJson,
|
||||
tokenJson = tokenInfo,
|
||||
tootAccount = TootParser(context, linkHelper = LinkHelper.create(ti))
|
||||
.account(accountJson)
|
||||
?: error("can't parse user information."),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* サーバにアプリ情報を登録する。
|
||||
* ユーザ作成の手前で呼ばれる。
|
||||
*/
|
||||
suspend fun prepareClient(
|
||||
tootInstance: TootInstance,
|
||||
): JsonObject = prepareClientImpl(
|
||||
apiHost = apiHost ?: error("prepareClient: missing apiHost"),
|
||||
clientName = clientName,
|
||||
tootInstance = tootInstance,
|
||||
forceUpdateClient = false
|
||||
)
|
||||
|
||||
/**
|
||||
* ユーザ作成
|
||||
* - クライアントは登録済みであること
|
||||
*/
|
||||
|
||||
suspend fun createUser(
|
||||
clientInfo: JsonObject,
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
agreement: Boolean,
|
||||
reason: String?,
|
||||
) = api.createUser(
|
||||
apiHost = apiHost ?: error("createUser: missing apiHost"),
|
||||
clientCredential = clientInfo.string(KEY_CLIENT_CREDENTIAL)
|
||||
?: error("createUser: missing client credential"),
|
||||
username = username,
|
||||
email = email,
|
||||
password = password,
|
||||
agreement = agreement,
|
||||
reason = reason
|
||||
)
|
||||
}
|
|
@ -0,0 +1,171 @@
|
|||
package jp.juggler.subwaytooter.api.auth
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import jp.juggler.subwaytooter.api.TootApiClient
|
||||
import jp.juggler.subwaytooter.api.entity.Host
|
||||
import jp.juggler.subwaytooter.api.send
|
||||
import jp.juggler.subwaytooter.column.encodeQuery
|
||||
import jp.juggler.util.data.JsonObject
|
||||
import jp.juggler.util.data.buildJsonObject
|
||||
import jp.juggler.util.data.jsonObjectOf
|
||||
import jp.juggler.util.data.notEmpty
|
||||
import jp.juggler.util.network.toFormRequestBody
|
||||
import jp.juggler.util.network.toPost
|
||||
import okhttp3.Request
|
||||
|
||||
class MastodonAuthApi(
|
||||
val client: TootApiClient,
|
||||
) {
|
||||
/**
|
||||
* クライアントアプリをサーバに登録する
|
||||
*/
|
||||
suspend fun registerClient(
|
||||
apiHost: Host,
|
||||
scopeString: String,
|
||||
clientName: String,
|
||||
callbackUrl: String,
|
||||
): JsonObject = jsonObjectOf(
|
||||
"client_name" to clientName,
|
||||
"redirect_uris" to callbackUrl,
|
||||
"scopes" to scopeString,
|
||||
).encodeQuery().toFormRequestBody().toPost()
|
||||
.url("https://${apiHost.ascii}/api/v1/apps")
|
||||
.build()
|
||||
.send(client, errorSuffix = apiHost.pretty)
|
||||
.readJsonObject()
|
||||
|
||||
/**
|
||||
* サーバ上に登録されたアプリを参照する client credential を作成する
|
||||
*/
|
||||
suspend fun createClientCredential(
|
||||
apiHost: Host,
|
||||
clientId: String,
|
||||
clientSecret: String,
|
||||
callbackUrl: String,
|
||||
): JsonObject = buildJsonObject {
|
||||
put("grant_type", "client_credentials")
|
||||
put("scope", "read write") // 空白は + に変換されること
|
||||
put("client_id", clientId)
|
||||
put("client_secret", clientSecret)
|
||||
put("redirect_uri", callbackUrl)
|
||||
}.encodeQuery().toFormRequestBody().toPost()
|
||||
.url("https://${apiHost.ascii}/oauth/token")
|
||||
.build()
|
||||
.send(client, errorSuffix = apiHost.pretty)
|
||||
.readJsonObject()
|
||||
|
||||
/**
|
||||
* client credentialを使って、サーバ上に登録されたクライアントアプリの情報を取得する
|
||||
* - クライアント情報がまだ有効か調べるのに使う
|
||||
*/
|
||||
// client_credentialがまだ有効か調べる
|
||||
suspend fun verifyClientCredential(
|
||||
apiHost: Host,
|
||||
clientCredential: String,
|
||||
): JsonObject = Request.Builder()
|
||||
.url("https://${apiHost.ascii}/api/v1/apps/verify_credentials")
|
||||
.header("Authorization", "Bearer $clientCredential")
|
||||
.build()
|
||||
.send(client, errorSuffix = apiHost.pretty)
|
||||
.readJsonObject()
|
||||
|
||||
/**
|
||||
* client credentialを削除する
|
||||
* - クライアント情報そのものは消えない…
|
||||
*/
|
||||
suspend fun revokeClientCredential(
|
||||
apiHost: Host,
|
||||
clientId: String,
|
||||
clientSecret: String,
|
||||
clientCredential: String,
|
||||
): JsonObject = buildJsonObject {
|
||||
put("client_id", clientId)
|
||||
put("client_secret", clientSecret)
|
||||
put("token", clientCredential)
|
||||
}.encodeQuery().toFormRequestBody().toPost()
|
||||
.url("https://${apiHost.ascii}/oauth/revoke")
|
||||
.build()
|
||||
.send(client, errorSuffix = apiHost.pretty)
|
||||
.readJsonObject()
|
||||
|
||||
// 認証ページURLを作る
|
||||
fun createAuthUrl(
|
||||
apiHost: Host,
|
||||
scopeString: String,
|
||||
callbackUrl: String,
|
||||
clientId: String,
|
||||
state: String,
|
||||
): Uri = buildJsonObject {
|
||||
put("client_id", clientId)
|
||||
put("response_type", "code")
|
||||
put("redirect_uri", callbackUrl)
|
||||
put("scope", scopeString)
|
||||
put("state", state)
|
||||
put("grant_type", "authorization_code")
|
||||
put("approval_prompt", "force")
|
||||
put("force_login", "true")
|
||||
// +"&access_type=offline"
|
||||
}.encodeQuery()
|
||||
.let { "https://${apiHost.ascii}/oauth/authorize?$it" }
|
||||
.toUri()
|
||||
|
||||
/**
|
||||
* ブラウザから帰ってきたコードを使い、認証の続きを行う
|
||||
*/
|
||||
suspend fun authStep2(
|
||||
apiHost: Host,
|
||||
clientId: String,
|
||||
clientSecret: String,
|
||||
scopeString: String,
|
||||
callbackUrl: String,
|
||||
code: String,
|
||||
): JsonObject = jsonObjectOf(
|
||||
"grant_type" to "authorization_code",
|
||||
"code" to code,
|
||||
"client_id" to clientId,
|
||||
"client_secret" to clientSecret,
|
||||
"scope" to scopeString,
|
||||
"redirect_uri" to callbackUrl,
|
||||
).encodeQuery().toFormRequestBody().toPost()
|
||||
.url("https://${apiHost.ascii}/oauth/token")
|
||||
.build()
|
||||
.send(client)
|
||||
.readJsonObject()
|
||||
|
||||
// 認証されたアカウントのユーザ情報を取得する
|
||||
suspend fun verifyAccount(
|
||||
apiHost: Host,
|
||||
accessToken: String,
|
||||
): JsonObject = Request.Builder()
|
||||
.url("https://${apiHost.ascii}/api/v1/accounts/verify_credentials")
|
||||
.header("Authorization", "Bearer $accessToken")
|
||||
.build()
|
||||
.send(client, errorSuffix = apiHost.pretty)
|
||||
.readJsonObject()
|
||||
|
||||
/**
|
||||
* ユーザ登録API
|
||||
* アクセストークンはあるがアカウントIDがない状態になる。
|
||||
*/
|
||||
suspend fun createUser(
|
||||
apiHost: Host,
|
||||
clientCredential: String,
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
agreement: Boolean,
|
||||
reason: String?,
|
||||
) = buildJsonObject {
|
||||
put("username", username)
|
||||
put("email", email)
|
||||
put("password", password)
|
||||
put("agreement", agreement)
|
||||
reason?.notEmpty()?.let { put("reason", reason) }
|
||||
}.encodeQuery().toFormRequestBody().toPost()
|
||||
.url("https://${apiHost.ascii}/api/v1/accounts")
|
||||
.header("Authorization", "Bearer $clientCredential")
|
||||
.build()
|
||||
.send(client, errorSuffix = apiHost.pretty)
|
||||
.readJsonObject()
|
||||
}
|
|
@ -0,0 +1,273 @@
|
|||
package jp.juggler.subwaytooter.api.auth
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import jp.juggler.subwaytooter.R
|
||||
import jp.juggler.subwaytooter.api.TootApiClient
|
||||
import jp.juggler.subwaytooter.api.TootParser
|
||||
import jp.juggler.subwaytooter.api.entity.EntityId
|
||||
import jp.juggler.subwaytooter.api.entity.Host
|
||||
import jp.juggler.subwaytooter.api.entity.TootInstance
|
||||
import jp.juggler.subwaytooter.pref.PrefDevice
|
||||
import jp.juggler.subwaytooter.table.ClientInfo
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.subwaytooter.util.LinkHelper
|
||||
import jp.juggler.util.data.*
|
||||
import jp.juggler.util.log.LogCategory
|
||||
|
||||
class MisskeyAuth10(override val client: TootApiClient) : AuthBase() {
|
||||
companion object {
|
||||
private val log = LogCategory("MisskeyOldAuth")
|
||||
private const val callbackUrl = "subwaytooter://misskey/auth_callback"
|
||||
|
||||
fun isCallbackUrl(uriStr: String) =
|
||||
uriStr.startsWith(callbackUrl) ||
|
||||
uriStr.startsWith("misskeyclientproto://misskeyclientproto/auth_callback")
|
||||
|
||||
fun getScopeArrayMisskey(ti: TootInstance?) =
|
||||
buildJsonArray {
|
||||
if (ti != null && !ti.versionGE(TootInstance.MISSKEY_VERSION_11)) {
|
||||
// https://github.com/syuilo/misskey/issues/2341
|
||||
// Misskey 10まで
|
||||
arrayOf(
|
||||
"account-read",
|
||||
"account-write",
|
||||
"account/read",
|
||||
"account/write",
|
||||
"drive-read",
|
||||
"drive-write",
|
||||
"favorite-read",
|
||||
"favorite-write",
|
||||
"favorites-read",
|
||||
"following-read",
|
||||
"following-write",
|
||||
"messaging-read",
|
||||
"messaging-write",
|
||||
"note-read",
|
||||
"note-write",
|
||||
"notification-read",
|
||||
"notification-write",
|
||||
"reaction-read",
|
||||
"reaction-write",
|
||||
"vote-read",
|
||||
"vote-write"
|
||||
)
|
||||
} else {
|
||||
// Misskey 11以降
|
||||
// https://github.com/syuilo/misskey/blob/master/src/server/api/kinds.ts
|
||||
arrayOf(
|
||||
"read:account",
|
||||
"write:account",
|
||||
"read:blocks",
|
||||
"write:blocks",
|
||||
"read:drive",
|
||||
"write:drive",
|
||||
"read:favorites",
|
||||
"write:favorites",
|
||||
"read:following",
|
||||
"write:following",
|
||||
"read:messaging",
|
||||
"write:messaging",
|
||||
"read:mutes",
|
||||
"write:mutes",
|
||||
"write:notes",
|
||||
"read:notifications",
|
||||
"write:notifications",
|
||||
"read:reactions",
|
||||
"write:reactions",
|
||||
"write:votes"
|
||||
)
|
||||
}.toMutableSet().forEach { add(it) }
|
||||
// APIのエラーを回避するため、重複を排除する
|
||||
}
|
||||
|
||||
fun JsonArray.encodeScopeArray() =
|
||||
stringArrayList().sorted().joinToString(",")
|
||||
|
||||
fun compareScopeArray(a: JsonArray, b: JsonArray?) =
|
||||
a.encodeScopeArray() == b?.encodeScopeArray()
|
||||
}
|
||||
|
||||
val api = MisskeyAuthApi10(client)
|
||||
|
||||
/**
|
||||
* Misskey v12 までの認証に使うURLを生成する
|
||||
*
|
||||
* {"token":"0ba88e2d-4b7d-4599-8d90-dc341a005637","url":"https://misskey.xyz/auth/0ba88e2d-4b7d-4599-8d90-dc341a005637"}
|
||||
*/
|
||||
private suspend fun createAuthUri(apiHost: Host, appSecret: String): Uri {
|
||||
PrefDevice.from(context).edit().apply {
|
||||
putString(PrefDevice.LAST_AUTH_INSTANCE, apiHost.ascii)
|
||||
putString(PrefDevice.LAST_AUTH_SECRET, appSecret)
|
||||
when (val account = account) {
|
||||
null -> remove(PrefDevice.LAST_AUTH_DB_ID)
|
||||
else -> putLong(PrefDevice.LAST_AUTH_DB_ID, account.db_id)
|
||||
}
|
||||
}.apply()
|
||||
return api.authSessionGenerate(apiHost, appSecret)
|
||||
.string("url").notEmpty()?.toUri()
|
||||
?: error("missing 'url' in session/generate.")
|
||||
}
|
||||
|
||||
/**
|
||||
* クライアントを登録してブラウザで開くURLを生成する
|
||||
* 成功したら TootApiResult.data にURL文字列を格納すること
|
||||
*
|
||||
* @param ti サーバ情報。Mastodonのホワイトリストモードではnullかもしれない
|
||||
* @param forceUpdateClient (Mastodon)クライアントを強制的に登録しなおす
|
||||
*/
|
||||
override suspend fun authStep1(
|
||||
ti: TootInstance?,
|
||||
forceUpdateClient: Boolean,
|
||||
): Uri {
|
||||
val apiHost = apiHost ?: error("missing apiHost")
|
||||
|
||||
val clientInfo = ClientInfo.load(apiHost, clientName)
|
||||
|
||||
// スコープ一覧を取得する
|
||||
val scopeArray = getScopeArrayMisskey(ti)
|
||||
|
||||
if (clientInfo != null &&
|
||||
AUTH_VERSION == clientInfo.int(KEY_AUTH_VERSION) &&
|
||||
clientInfo.boolean(KEY_IS_MISSKEY) == true
|
||||
) {
|
||||
val appSecret = clientInfo.string(KEY_MISSKEY_APP_SECRET)
|
||||
|
||||
val appId = clientInfo.string("id")
|
||||
?: error("missing app id")
|
||||
|
||||
// tmpClientInfo はsecretを含まないので保存してはいけない
|
||||
val tmpClientInfo = try {
|
||||
api.appShow(apiHost, appId)
|
||||
} catch (ex: Throwable) {
|
||||
// アプリ情報の取得に失敗しても致命的ではない
|
||||
log.e(ex, "can't get app info, but continue…")
|
||||
null
|
||||
}
|
||||
|
||||
// - アプリが登録済みで
|
||||
// - クライアント名が一致してて
|
||||
// - パーミッションが同じ
|
||||
// ならクライアント情報を再利用する
|
||||
if (tmpClientInfo != null &&
|
||||
clientName == tmpClientInfo.string("name") &&
|
||||
compareScopeArray(scopeArray, tmpClientInfo["permission"].cast()) &&
|
||||
appSecret?.isNotEmpty() == true
|
||||
) return createAuthUri(apiHost, appSecret)
|
||||
|
||||
// XXX appSecretを使ってクライアント情報を削除できるようにするべきだが、該当するAPIが存在しない
|
||||
}
|
||||
|
||||
val appJson = api.appCreate(
|
||||
apiHost = apiHost,
|
||||
appNameId = appNameId,
|
||||
appDescription = appDescription,
|
||||
clientName = clientName,
|
||||
scopeArray = scopeArray,
|
||||
callbackUrl = callbackUrl,
|
||||
).apply {
|
||||
put(KEY_IS_MISSKEY, true)
|
||||
put(KEY_AUTH_VERSION, AUTH_VERSION)
|
||||
}
|
||||
|
||||
val appSecret = appJson.string(KEY_MISSKEY_APP_SECRET)
|
||||
.notBlank() ?: error(context.getString(R.string.cant_get_misskey_app_secret))
|
||||
|
||||
ClientInfo.save(apiHost, clientName, appJson.toString())
|
||||
|
||||
return createAuthUri(apiHost, appSecret)
|
||||
}
|
||||
|
||||
/**
|
||||
* Misskey(v12まで)の認証コールバックUriを処理する
|
||||
*/
|
||||
override suspend fun authStep2(uri: Uri): Auth2Result {
|
||||
|
||||
val prefDevice = PrefDevice.from(context)
|
||||
|
||||
val token = uri.getQueryParameter("token")
|
||||
?.notBlank() ?: error("missing token in callback URL")
|
||||
|
||||
val hostStr = prefDevice.getString(PrefDevice.LAST_AUTH_INSTANCE, null)
|
||||
?.notBlank() ?: error("missing instance name.")
|
||||
val apiHost = Host.parse(hostStr)
|
||||
|
||||
when (val dbId = prefDevice.getLong(PrefDevice.LAST_AUTH_DB_ID, -1L)) {
|
||||
// new registration
|
||||
-1L -> client.apiHost = apiHost
|
||||
// update access token
|
||||
else -> SavedAccount.loadAccount(context, dbId)?.also {
|
||||
client.account = it
|
||||
} ?: error("missing account db_id=$dbId")
|
||||
}
|
||||
|
||||
val (ti, r2) = TootInstance.get(client)
|
||||
ti ?: error("${r2?.error} ($apiHost)")
|
||||
|
||||
val parser = TootParser(
|
||||
context,
|
||||
linkHelper = LinkHelper.create(ti)
|
||||
)
|
||||
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val clientInfo = ClientInfo.load(apiHost, clientName)
|
||||
?.notEmpty() ?: error("missing client id")
|
||||
|
||||
val appSecret = clientInfo.string(KEY_MISSKEY_APP_SECRET)
|
||||
?.notEmpty() ?: error(context.getString(R.string.cant_get_misskey_app_secret))
|
||||
|
||||
val tokenInfo = api.authSessionUserKey(
|
||||
apiHost,
|
||||
appSecret,
|
||||
token,
|
||||
)
|
||||
|
||||
// {"accessToken":"...","user":{…}}
|
||||
|
||||
val accessToken = tokenInfo.string("accessToken")
|
||||
?.notBlank() ?: error("missing accessToken in the userkey response.")
|
||||
|
||||
val accountJson = tokenInfo["user"].cast<JsonObject>()
|
||||
?: error("missing user in the userkey response.")
|
||||
|
||||
val tootAccount = parser.account(accountJson)
|
||||
?: error("can't parse user information")
|
||||
|
||||
tokenInfo.remove("user")
|
||||
|
||||
return Auth2Result(
|
||||
tootInstance = ti,
|
||||
accountJson = accountJson,
|
||||
tokenJson = tokenInfo.also {
|
||||
EntityId.mayNull(accountJson.string("id"))?.putTo(it, KEY_USER_ID)
|
||||
it[KEY_MISSKEY_VERSION] = ti.misskeyVersionMajor
|
||||
it[KEY_AUTH_VERSION] = AUTH_VERSION
|
||||
val apiKey = "$accessToken$appSecret".encodeUTF8().digestSHA256().encodeHexLower()
|
||||
it[KEY_API_KEY_MISSKEY] = apiKey
|
||||
},
|
||||
tootAccount = tootAccount,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* アクセストークンを指定してユーザ情報を取得する。
|
||||
* - アクセストークンの手動入力などで使われる
|
||||
*
|
||||
* 副作用:ユーザ情報を取得できたら outTokenInfo にアクセストークンを格納する。
|
||||
*/
|
||||
override suspend fun verifyAccount(
|
||||
accessToken: String,
|
||||
outTokenJson: JsonObject?,
|
||||
misskeyVersion: Int,
|
||||
): JsonObject = api.verifyAccount(
|
||||
apiHost = apiHost ?: error("missing apiHost"),
|
||||
accessToken = accessToken
|
||||
).also {
|
||||
// ユーザ情報が読めたら outTokenInfo にアクセストークンを保存する
|
||||
outTokenJson?.apply {
|
||||
put(KEY_AUTH_VERSION, AUTH_VERSION)
|
||||
put(KEY_API_KEY_MISSKEY, accessToken)
|
||||
put(KEY_MISSKEY_VERSION, misskeyVersion)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,149 @@
|
|||
package jp.juggler.subwaytooter.api.auth
|
||||
|
||||
import android.net.Uri
|
||||
import jp.juggler.subwaytooter.api.TootApiClient
|
||||
import jp.juggler.subwaytooter.api.TootParser
|
||||
import jp.juggler.subwaytooter.api.auth.MisskeyAuth10.Companion.encodeScopeArray
|
||||
import jp.juggler.subwaytooter.api.auth.MisskeyAuth10.Companion.getScopeArrayMisskey
|
||||
import jp.juggler.subwaytooter.api.entity.Host
|
||||
import jp.juggler.subwaytooter.api.entity.TootInstance
|
||||
import jp.juggler.subwaytooter.pref.PrefDevice
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.subwaytooter.util.LinkHelper
|
||||
import jp.juggler.util.data.JsonObject
|
||||
import jp.juggler.util.data.notEmpty
|
||||
import jp.juggler.util.log.LogCategory
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* miauth と呼ばれている認証手順。
|
||||
* STではMisskey 13から使用する。
|
||||
*/
|
||||
class MisskeyAuth13(override val client: TootApiClient) : AuthBase() {
|
||||
companion object {
|
||||
private val log = LogCategory("MisskeyMiAuth")
|
||||
private const val appIconUrl = "https://m1j.zzz.ac/subwaytooter-miauth-icon.png"
|
||||
private const val callbackUrl = "subwaytooter://miauth/auth_callback"
|
||||
|
||||
fun isCallbackUrl(uriStr: String) = uriStr.startsWith(callbackUrl)
|
||||
}
|
||||
|
||||
private val api10 = MisskeyAuthApi10(client)
|
||||
private val api13 = MisskeyAuthApi13(client)
|
||||
|
||||
// 認証されたアカウントのユーザ情報を取得する
|
||||
override suspend fun verifyAccount(
|
||||
accessToken: String,
|
||||
outTokenJson: JsonObject?,
|
||||
misskeyVersion: Int,
|
||||
): JsonObject = api10.verifyAccount(
|
||||
apiHost = apiHost ?: error("missing apiHost"),
|
||||
accessToken = accessToken,
|
||||
).also {
|
||||
// ユーザ情報を読めたならtokenInfoを保存する
|
||||
outTokenJson?.apply {
|
||||
put(KEY_AUTH_VERSION, AUTH_VERSION)
|
||||
put(KEY_API_KEY_MISSKEY, accessToken)
|
||||
put(KEY_MISSKEY_VERSION, misskeyVersion)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* クライアントを登録してブラウザで開くURLを生成する
|
||||
* 成功したら TootApiResult.data にURL文字列を格納すること
|
||||
*/
|
||||
override suspend fun authStep1(
|
||||
ti: TootInstance?,
|
||||
forceUpdateClient: Boolean,
|
||||
): Uri {
|
||||
val apiHost = apiHost ?: error("missing apiHost")
|
||||
|
||||
val sessionId = UUID.randomUUID().toString()
|
||||
|
||||
PrefDevice.from(client.context).edit().apply {
|
||||
putString(PrefDevice.LAST_AUTH_INSTANCE, apiHost.ascii)
|
||||
putString(PrefDevice.LAST_AUTH_SECRET, sessionId)
|
||||
when (val account = account) {
|
||||
null -> remove(PrefDevice.LAST_AUTH_DB_ID)
|
||||
else -> putLong(PrefDevice.LAST_AUTH_DB_ID, account.db_id)
|
||||
}
|
||||
}.apply()
|
||||
|
||||
return api13.createAuthUrl(
|
||||
apiHost = apiHost,
|
||||
clientName = clientName,
|
||||
iconUrl = appIconUrl,
|
||||
callbackUrl = callbackUrl,
|
||||
permission = getScopeArrayMisskey(ti).encodeScopeArray(),
|
||||
sessionId = sessionId,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun authStep2(uri: Uri): Auth2Result {
|
||||
|
||||
// 認証開始時に保存した情報
|
||||
val prefDevice = PrefDevice.from(client.context)
|
||||
val savedSessionId = prefDevice.getString(PrefDevice.LAST_AUTH_SECRET, null)
|
||||
|
||||
val apiHost = prefDevice.getString(PrefDevice.LAST_AUTH_INSTANCE, null)
|
||||
?.let { Host.parse(it) }
|
||||
?: error("missing apiHost")
|
||||
|
||||
when (val dbId = prefDevice.getLong(PrefDevice.LAST_AUTH_DB_ID, -1L)) {
|
||||
// new registration
|
||||
-1L -> client.apiHost = apiHost
|
||||
|
||||
// update access token
|
||||
else -> {
|
||||
val sa = SavedAccount.loadAccount(context, dbId)
|
||||
?: error("missing account db_id=$dbId")
|
||||
client.account = sa
|
||||
}
|
||||
}
|
||||
|
||||
// コールバックURLに含まれるセッションID
|
||||
val sessionId = uri.getQueryParameter("session").notEmpty()
|
||||
?: error("missing sessionId in callback URL")
|
||||
|
||||
if (sessionId != savedSessionId) {
|
||||
error("auth session id not match.")
|
||||
}
|
||||
|
||||
val (ti, r2) = TootInstance.get(client)
|
||||
ti ?: error("missing server information. ${r2?.error}")
|
||||
|
||||
val misskeyVersion = ti.misskeyVersionMajor
|
||||
|
||||
val data = api13.checkAuthSession(apiHost, sessionId)
|
||||
|
||||
val ok = data.boolean("ok")
|
||||
if (ok != true) {
|
||||
error("Authentication result is not ok. [$ok]")
|
||||
}
|
||||
|
||||
val apiKey = data.string("token")
|
||||
?: error("missing token.")
|
||||
log.i("apiKey=$apiKey")
|
||||
|
||||
val accountJson = data.jsonObject("user")
|
||||
?: error("missing user.")
|
||||
|
||||
val user = TootParser(context, linkHelper = LinkHelper.create(ti))
|
||||
.account(accountJson)
|
||||
?: error("can't parse user json.")
|
||||
|
||||
prefDevice.edit().remove(PrefDevice.LAST_AUTH_SECRET).apply()
|
||||
|
||||
return Auth2Result(
|
||||
tootInstance = ti,
|
||||
accountJson = accountJson,
|
||||
tokenJson = JsonObject().apply {
|
||||
user.id.putTo(this, KEY_USER_ID)
|
||||
put(KEY_MISSKEY_VERSION, misskeyVersion)
|
||||
put(KEY_AUTH_VERSION, AUTH_VERSION)
|
||||
put(KEY_API_KEY_MISSKEY, apiKey)
|
||||
},
|
||||
tootAccount = user,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
package jp.juggler.subwaytooter.api.auth
|
||||
|
||||
import jp.juggler.subwaytooter.api.TootApiClient
|
||||
import jp.juggler.subwaytooter.api.entity.Host
|
||||
import jp.juggler.subwaytooter.api.send
|
||||
import jp.juggler.util.data.JsonArray
|
||||
import jp.juggler.util.data.JsonObject
|
||||
import jp.juggler.util.data.buildJsonObject
|
||||
import jp.juggler.util.data.jsonObjectOf
|
||||
import jp.juggler.util.network.toPostRequestBuilder
|
||||
|
||||
class MisskeyAuthApi10(val client: TootApiClient) {
|
||||
|
||||
suspend fun appCreate(
|
||||
apiHost: Host,
|
||||
appNameId: String,
|
||||
appDescription: String,
|
||||
clientName: String,
|
||||
scopeArray: JsonArray,
|
||||
callbackUrl: String,
|
||||
) = buildJsonObject {
|
||||
put("nameId", appNameId)
|
||||
put("name", clientName)
|
||||
put("description", appDescription)
|
||||
put("callbackUrl", callbackUrl)
|
||||
put("permission", scopeArray)
|
||||
}.toPostRequestBuilder()
|
||||
.url("https://${apiHost.ascii}/api/app/create")
|
||||
.build()
|
||||
.send(client, errorSuffix = apiHost.pretty)
|
||||
.readJsonObject()
|
||||
|
||||
suspend fun appShow(apiHost: Host, appId: String) =
|
||||
jsonObjectOf("appId" to appId)
|
||||
.toPostRequestBuilder()
|
||||
.url("https://${apiHost.ascii}/api/app/show")
|
||||
.build()
|
||||
.send(client, errorSuffix = apiHost.pretty)
|
||||
.readJsonObject()
|
||||
|
||||
suspend fun authSessionGenerate(apiHost: Host, appSecret: String) =
|
||||
jsonObjectOf("appSecret" to appSecret)
|
||||
.toPostRequestBuilder()
|
||||
.url("https://${apiHost.ascii}/api/auth/session/generate")
|
||||
.build()
|
||||
.send(client, errorSuffix = apiHost.pretty)
|
||||
.readJsonObject()
|
||||
|
||||
suspend fun authSessionUserKey(
|
||||
apiHost: Host,
|
||||
appSecret: String,
|
||||
token: String,
|
||||
): JsonObject = jsonObjectOf(
|
||||
"appSecret" to appSecret,
|
||||
"token" to token,
|
||||
).toPostRequestBuilder()
|
||||
.url("https://${apiHost}/api/auth/session/userkey")
|
||||
.build()
|
||||
.send(client, errorSuffix = apiHost.pretty)
|
||||
.readJsonObject()
|
||||
|
||||
suspend fun verifyAccount(
|
||||
apiHost: Host,
|
||||
accessToken: String,
|
||||
): JsonObject = jsonObjectOf("i" to accessToken)
|
||||
.toPostRequestBuilder()
|
||||
.url("https://${apiHost.ascii}/api/i")
|
||||
.build()
|
||||
.send(client, errorSuffix = apiHost.pretty)
|
||||
.readJsonObject()
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
package jp.juggler.subwaytooter.api.auth
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import jp.juggler.subwaytooter.api.TootApiClient
|
||||
import jp.juggler.subwaytooter.api.entity.Host
|
||||
import jp.juggler.subwaytooter.api.send
|
||||
import jp.juggler.subwaytooter.column.encodeQuery
|
||||
import jp.juggler.util.data.JsonObject
|
||||
import jp.juggler.util.data.jsonObjectOf
|
||||
import jp.juggler.util.network.toPostRequestBuilder
|
||||
|
||||
class MisskeyAuthApi13(val client: TootApiClient) {
|
||||
|
||||
/**
|
||||
* miauth のブラウザ認証URLを作成する
|
||||
*/
|
||||
fun createAuthUrl(
|
||||
apiHost: Host,
|
||||
clientName: String,
|
||||
iconUrl: String,
|
||||
callbackUrl: String,
|
||||
permission: String,
|
||||
sessionId: String,
|
||||
): Uri = jsonObjectOf(
|
||||
"name" to clientName,
|
||||
"icon" to iconUrl,
|
||||
"callback" to callbackUrl,
|
||||
"permission" to permission
|
||||
).encodeQuery()
|
||||
.let { "https://${apiHost.ascii}/miauth/$sessionId?$it" }
|
||||
.toUri()
|
||||
|
||||
/**
|
||||
* miauthの認証結果を確認する
|
||||
*/
|
||||
suspend fun checkAuthSession(
|
||||
apiHost: Host,
|
||||
sessionId: String,
|
||||
): JsonObject = JsonObject(/*empty*/)
|
||||
.toPostRequestBuilder()
|
||||
.url("https://${apiHost.ascii}/api/miauth/${sessionId}/check")
|
||||
.build()
|
||||
.send(client, errorSuffix = apiHost.pretty)
|
||||
.readJsonObject()
|
||||
|
||||
|
||||
}
|
|
@ -4,6 +4,7 @@ import android.os.SystemClock
|
|||
import jp.juggler.subwaytooter.api.TootApiClient
|
||||
import jp.juggler.subwaytooter.api.TootApiResult
|
||||
import jp.juggler.subwaytooter.api.TootParser
|
||||
import jp.juggler.subwaytooter.api.auth.AuthBase
|
||||
import jp.juggler.subwaytooter.pref.PrefB
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.subwaytooter.util.LinkHelper
|
||||
|
@ -89,9 +90,12 @@ class TootInstance(parser: TootParser, src: JsonObject) {
|
|||
val isExpired: Boolean
|
||||
get() = SystemClock.elapsedRealtime() - time_parse >= EXPIRE
|
||||
|
||||
// サーバのAPIホスト
|
||||
val apiHost: Host = parser.apiHost
|
||||
|
||||
// URI of the current instance
|
||||
// apiHost ではなく apDomain を示す
|
||||
val uri: String?
|
||||
val apDomain: Host
|
||||
|
||||
// The instance's title
|
||||
val title: String?
|
||||
|
@ -153,7 +157,8 @@ class TootInstance(parser: TootParser, src: JsonObject) {
|
|||
|
||||
this.misskeyEndpoints = src.jsonArray("_endpoints")?.stringList()?.toSet()
|
||||
|
||||
this.uri = parser.apiHost.ascii
|
||||
// Misskeyは apiHost と apDomain の区別がない
|
||||
this.apDomain = parser.apiHost
|
||||
this.title = parser.apiHost.pretty
|
||||
val sv = src.jsonObject("maintainer")?.string("url")
|
||||
this.email = when {
|
||||
|
@ -178,7 +183,7 @@ class TootInstance(parser: TootParser, src: JsonObject) {
|
|||
|
||||
this.invites_enabled = null
|
||||
} else {
|
||||
this.uri = src.string("uri")
|
||||
this.apDomain = src.string("uri")?.let { Host.parse(it) } ?: parser.apDomain
|
||||
this.title = src.string("title")
|
||||
|
||||
val sv = src.string("email")
|
||||
|
@ -202,12 +207,18 @@ class TootInstance(parser: TootParser, src: JsonObject) {
|
|||
|
||||
languages = src.jsonArray("languages")?.stringArrayList()
|
||||
|
||||
val parser2 = TootParser(
|
||||
parser.context,
|
||||
LinkHelper.create(Host.parse(uri ?: "?"))
|
||||
contact_account = parseItem(
|
||||
::TootAccount,
|
||||
TootParser(
|
||||
parser.context,
|
||||
LinkHelper.create(
|
||||
apiHostArg = apiHost,
|
||||
apDomainArg = apDomain,
|
||||
misskeyVersion = 0,
|
||||
)
|
||||
),
|
||||
src.jsonObject("contact_account")
|
||||
)
|
||||
contact_account =
|
||||
parseItem(::TootAccount, parser2, src.jsonObject("contact_account"))
|
||||
|
||||
this.description = src.string("description")
|
||||
this.short_description = src.string("short_description")
|
||||
|
@ -233,7 +244,7 @@ class TootInstance(parser: TootParser, src: JsonObject) {
|
|||
val domain_count = src.long("domain_count") ?: -1L
|
||||
}
|
||||
|
||||
val misskeyVersion: Int
|
||||
val misskeyVersionMajor: Int
|
||||
get() = when {
|
||||
instanceType != InstanceType.Misskey -> 0
|
||||
else -> decoded_version.majorVersion ?: 10
|
||||
|
@ -271,6 +282,7 @@ class TootInstance(parser: TootParser, src: JsonObject) {
|
|||
val MISSKEY_VERSION_11 = VersionString("11.0")
|
||||
val MISSKEY_VERSION_12 = VersionString("12.0")
|
||||
val MISSKEY_VERSION_12_75_0 = VersionString("12.75.0")
|
||||
val MISSKEY_VERSION_13 = VersionString("13.0")
|
||||
|
||||
private val reDigits = """(\d+)""".asciiPattern()
|
||||
|
||||
|
@ -279,9 +291,10 @@ class TootInstance(parser: TootParser, src: JsonObject) {
|
|||
const val DESCRIPTION_DEFAULT = "(no description)"
|
||||
|
||||
// 引数はtoken_infoかTootInstanceのパース前のいずれか
|
||||
fun parseMisskeyVersion(tokenInfo: JsonObject): Int {
|
||||
return when (val o = tokenInfo[TootApiClient.KEY_MISSKEY_VERSION]) {
|
||||
is Int -> o
|
||||
private fun parseMisskeyVersion(tokenInfo: JsonObject): Int {
|
||||
log.i("parseMisskeyVersion ${AuthBase.KEY_MISSKEY_VERSION}=${tokenInfo[AuthBase.KEY_MISSKEY_VERSION]}, (version)=${tokenInfo["version"]}")
|
||||
return when (val o = tokenInfo[AuthBase.KEY_MISSKEY_VERSION]) {
|
||||
is Int -> tokenInfo.string("version")?.let { VersionString(it).majorVersion } ?: o
|
||||
is Boolean -> if (o) 10 else 0
|
||||
else -> 0
|
||||
}
|
||||
|
@ -291,7 +304,7 @@ class TootInstance(parser: TootParser, src: JsonObject) {
|
|||
private suspend fun TootApiClient.getInstanceInformationMastodon(
|
||||
forceAccessToken: String? = null,
|
||||
): TootApiResult? {
|
||||
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
|
||||
val result = TootApiResult.makeWithCaption(apiHost)
|
||||
if (result.error != null) return result
|
||||
|
||||
if (sendRequest(result) {
|
||||
|
@ -311,7 +324,7 @@ class TootInstance(parser: TootParser, src: JsonObject) {
|
|||
private suspend fun TootApiClient.getMisskeyEndpoints(
|
||||
forceAccessToken: String? = null,
|
||||
): TootApiResult? {
|
||||
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
|
||||
val result = TootApiResult.makeWithCaption(apiHost)
|
||||
if (result.error != null) return result
|
||||
|
||||
if (sendRequest(result) {
|
||||
|
@ -332,7 +345,7 @@ class TootInstance(parser: TootParser, src: JsonObject) {
|
|||
private suspend fun TootApiClient.getInstanceInformationMisskey(
|
||||
forceAccessToken: String? = null,
|
||||
): TootApiResult? {
|
||||
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
|
||||
val result = TootApiResult.makeWithCaption(apiHost)
|
||||
if (result.error != null) return result
|
||||
|
||||
if (sendRequest(result) {
|
||||
|
@ -350,7 +363,7 @@ class TootInstance(parser: TootParser, src: JsonObject) {
|
|||
result.jsonObject?.apply {
|
||||
val m = reDigits.matcher(string("version") ?: "")
|
||||
if (m.find()) {
|
||||
put(TootApiClient.KEY_MISSKEY_VERSION, max(1, m.groupEx(1)!!.toInt()))
|
||||
put(AuthBase.KEY_MISSKEY_VERSION, max(1, m.groupEx(1)!!.toInt()))
|
||||
}
|
||||
|
||||
// add endpoints
|
||||
|
@ -574,4 +587,5 @@ class TootInstance(parser: TootParser, src: JsonObject) {
|
|||
}
|
||||
|
||||
val isMastodon get() = instanceType == InstanceType.Mastodon
|
||||
val isMisskey get() = misskeyVersionMajor > 0
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.os.SystemClock
|
|||
import jp.juggler.subwaytooter.App1
|
||||
import jp.juggler.subwaytooter.DedupMode
|
||||
import jp.juggler.subwaytooter.api.TootApiClient
|
||||
import jp.juggler.subwaytooter.api.auth.AuthBase
|
||||
import jp.juggler.subwaytooter.api.entity.*
|
||||
import jp.juggler.subwaytooter.columnviewholder.*
|
||||
import jp.juggler.subwaytooter.notification.injectData
|
||||
|
@ -374,7 +375,7 @@ fun Column.onMisskeyNoteUpdated(ev: MisskeyNoteUpdate) {
|
|||
// userId が自分かどうか調べる
|
||||
// アクセストークンの更新をして自分のuserIdが分かる状態でないとキャプチャ結果を反映させない
|
||||
// (でないとリアクションの2重カウントなどが発生してしまう)
|
||||
val myId = EntityId.from(accessInfo.token_info, TootApiClient.KEY_USER_ID)
|
||||
val myId = EntityId.from(accessInfo.token_info, AuthBase.KEY_USER_ID)
|
||||
if (myId == null) {
|
||||
log.w("onNoteUpdated: missing my userId. updating access token is recommenced!!")
|
||||
}
|
||||
|
|
|
@ -136,7 +136,7 @@ abstract class ColumnTask(
|
|||
fun start() {
|
||||
job = launchMain {
|
||||
val result = try {
|
||||
withContext(AppDispatchers.io) { background() }
|
||||
withContext(AppDispatchers.IO) { background() }
|
||||
} catch (ignored: CancellationException) {
|
||||
null // キャンセルされたらresult==nullとする
|
||||
} catch (ex: Throwable) {
|
||||
|
|
|
@ -52,8 +52,7 @@ class ColumnTask_Loading(
|
|||
client.account = accessInfo
|
||||
|
||||
try {
|
||||
val result = accessInfo.checkConfirmed(context, client)
|
||||
if (result == null || result.error != null) return result
|
||||
accessInfo.checkConfirmed(context, client)
|
||||
|
||||
column.keywordFilterTrees = column.encodeFilterTree(column.loadFilter2(client))
|
||||
|
||||
|
@ -69,7 +68,6 @@ class ColumnTask_Loading(
|
|||
} catch (ex: Throwable) {
|
||||
return TootApiResult(ex.withCaption("loading failed."))
|
||||
} finally {
|
||||
|
||||
try {
|
||||
column.updateRelation(client, listTmp, column.whoAccount, parser)
|
||||
} catch (ex: Throwable) {
|
||||
|
|
|
@ -263,12 +263,12 @@ fun JsonObject.encodeQuery(): String {
|
|||
when (v) {
|
||||
null, is String, is Number, is Boolean -> {
|
||||
if (sb.isNotEmpty()) sb.append('&')
|
||||
sb.append(k).append('=').append(v.toString().encodePercent())
|
||||
sb.append(k).append('=').append(v.toString().encodePercentPlus())
|
||||
}
|
||||
is List<*> -> {
|
||||
v.forEach {
|
||||
if (sb.isNotEmpty()) sb.append('&')
|
||||
sb.append(k).append("[]=").append(it.toString().encodePercent())
|
||||
sb.append(k).append("[]=").append(it.toString().encodePercentPlus())
|
||||
}
|
||||
}
|
||||
else -> error("encodeQuery: unsupported type ${v.javaClass.name}")
|
||||
|
|
|
@ -70,7 +70,7 @@ fun ColumnViewHolder.loadBackgroundImage(iv: ImageView, url: String?) {
|
|||
// 非同期処理を開始
|
||||
lastImageTask = launchMain {
|
||||
val bitmap = try {
|
||||
withContext(AppDispatchers.io) {
|
||||
withContext(AppDispatchers.IO) {
|
||||
try {
|
||||
createResizedBitmap(
|
||||
activity,
|
||||
|
|
|
@ -106,20 +106,16 @@ internal class ViewHolderHeaderInstance(
|
|||
btnAboutMore.isEnabledAlpha = false
|
||||
btnExplore.isEnabledAlpha = false
|
||||
} else {
|
||||
val uri = instance.uri ?: ""
|
||||
val hasUri = uri.isNotEmpty()
|
||||
|
||||
val host = Host.parse(uri)
|
||||
btnInstance.text = if (host.ascii == host.pretty) {
|
||||
host.pretty
|
||||
} else {
|
||||
"${host.pretty}\n${host.ascii}"
|
||||
val domain = instance.apDomain
|
||||
btnInstance.text = when {
|
||||
domain.pretty != domain.ascii -> "${domain.pretty}\n${domain.ascii}"
|
||||
else -> domain.ascii
|
||||
}
|
||||
|
||||
btnInstance.isEnabledAlpha = hasUri
|
||||
btnAbout.isEnabledAlpha = hasUri
|
||||
btnAboutMore.isEnabledAlpha = hasUri
|
||||
btnExplore.isEnabledAlpha = hasUri
|
||||
btnInstance.isEnabledAlpha = true
|
||||
btnAbout.isEnabledAlpha = true
|
||||
btnAboutMore.isEnabledAlpha = true
|
||||
btnExplore.isEnabledAlpha = true
|
||||
|
||||
tvVersion.text = instance.version ?: ""
|
||||
tvTitle.text = instance.title ?: ""
|
||||
|
@ -170,7 +166,7 @@ internal class ViewHolderHeaderInstance(
|
|||
val thumbnail = instance.thumbnail?.let {
|
||||
if (it.startsWith("/")) {
|
||||
// "/instance/thumbnail.jpeg" in case of pleroma.noellabo.jp
|
||||
"https://${host.ascii}$it"
|
||||
"https://${instance.apiHost.ascii}$it"
|
||||
} else {
|
||||
it
|
||||
}
|
||||
|
|
|
@ -69,7 +69,7 @@ class DlgCreateAccount(
|
|||
activity,
|
||||
linkHelper = LinkHelper.create(
|
||||
apiHost,
|
||||
misskeyVersion = instanceInfo?.misskeyVersion ?: 0
|
||||
misskeyVersion = instanceInfo?.misskeyVersionMajor ?: 0
|
||||
)
|
||||
).decodeHTML(
|
||||
instanceInfo?.short_description?.notBlank()
|
||||
|
|
|
@ -127,7 +127,7 @@ class DlgDraftPicker : AdapterView.OnItemClickListener, AdapterView.OnItemLongCl
|
|||
|
||||
task = launchMain {
|
||||
val cursor = try {
|
||||
withContext(AppDispatchers.io) {
|
||||
withContext(AppDispatchers.IO) {
|
||||
PostDraft.createCursor()
|
||||
} ?: error("cursor is null")
|
||||
} catch (ignored: CancellationException) {
|
||||
|
|
|
@ -41,7 +41,7 @@ object LoginForm {
|
|||
instanceArg: String?,
|
||||
onClickOk: (
|
||||
dialog: Dialog,
|
||||
instance: Host,
|
||||
apiHost: Host,
|
||||
action: Action,
|
||||
) -> Unit,
|
||||
) {
|
||||
|
|
|
@ -211,7 +211,7 @@ class PollingChecker(
|
|||
return
|
||||
}
|
||||
|
||||
withContext(AppDispatchers.default + checkJob) {
|
||||
withContext(AppDispatchers.DEFAULT + checkJob) {
|
||||
if (importProtector.get()) {
|
||||
log.w("aborted by importProtector.")
|
||||
return@withContext
|
||||
|
|
|
@ -310,7 +310,7 @@ suspend fun checkNoticifationAll(
|
|||
SavedAccount.loadAccountList(context).mapNotNull { sa ->
|
||||
when {
|
||||
sa.isPseudo || !sa.isConfirmed -> null
|
||||
else -> EmptyScope.launch(AppDispatchers.default) {
|
||||
else -> EmptyScope.launch(AppDispatchers.DEFAULT) {
|
||||
try {
|
||||
PollingChecker(
|
||||
context = context,
|
||||
|
|
|
@ -3,6 +3,7 @@ package jp.juggler.subwaytooter.table
|
|||
import android.content.ContentValues
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.provider.BaseColumns
|
||||
import jp.juggler.subwaytooter.api.entity.Host
|
||||
import jp.juggler.subwaytooter.global.appDatabase
|
||||
import jp.juggler.util.data.*
|
||||
import jp.juggler.util.log.LogCategory
|
||||
|
@ -29,7 +30,8 @@ object ClientInfo : TableCompanion {
|
|||
override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) =
|
||||
columnList.onDBUpgrade(db, oldVersion, newVersion)
|
||||
|
||||
fun load(instance: String, clientName: String): JsonObject? {
|
||||
fun load(apiHost: Host, clientName: String): JsonObject? {
|
||||
val instance = apiHost.pretty
|
||||
try {
|
||||
appDatabase.query(
|
||||
table,
|
||||
|
@ -39,38 +41,37 @@ object ClientInfo : TableCompanion {
|
|||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
return cursor.getString(COL_RESULT).decodeJsonObject()
|
||||
}
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
return cursor.getString(COL_RESULT).decodeJsonObject()
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
log.e(ex, "load failed.")
|
||||
log.e(ex, "load failed. apiHost=$apiHost")
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun save(instance: String, clientName: String, json: String) {
|
||||
fun save(apiHost: Host, clientName: String, json: String) {
|
||||
try {
|
||||
val cv = ContentValues()
|
||||
cv.put(COL_HOST, instance)
|
||||
cv.put(COL_CLIENT_NAME, clientName)
|
||||
cv.put(COL_RESULT, json)
|
||||
val cv = ContentValues().apply {
|
||||
put(COL_HOST, apiHost.pretty)
|
||||
put(COL_CLIENT_NAME, clientName)
|
||||
put(COL_RESULT, json)
|
||||
}
|
||||
appDatabase.replace(table, null, cv)
|
||||
} catch (ex: Throwable) {
|
||||
log.e(ex, "save failed.")
|
||||
log.e(ex, "save failed. apiHost=$apiHost")
|
||||
}
|
||||
}
|
||||
|
||||
// 単体テスト用。インスタンス名を指定して削除する
|
||||
fun delete(instance: String, clientName: String) {
|
||||
fun delete(apiHost: Host, clientName: String) {
|
||||
try {
|
||||
appDatabase.delete(
|
||||
table,
|
||||
"$COL_HOST=? and $COL_CLIENT_NAME=?",
|
||||
arrayOf(instance, clientName)
|
||||
arrayOf(apiHost.pretty, clientName)
|
||||
)
|
||||
} catch (ex: Throwable) {
|
||||
log.e(ex, "delete failed.")
|
||||
|
|
|
@ -7,8 +7,9 @@ import android.database.sqlite.SQLiteDatabase
|
|||
import android.provider.BaseColumns
|
||||
import jp.juggler.subwaytooter.R
|
||||
import jp.juggler.subwaytooter.api.TootApiClient
|
||||
import jp.juggler.subwaytooter.api.TootApiResult
|
||||
import jp.juggler.subwaytooter.api.TootParser
|
||||
import jp.juggler.subwaytooter.api.auth.Auth2Result
|
||||
import jp.juggler.subwaytooter.api.auth.AuthBase
|
||||
import jp.juggler.subwaytooter.api.entity.*
|
||||
import jp.juggler.subwaytooter.global.appDatabase
|
||||
import jp.juggler.subwaytooter.notification.checkNotificationImmediate
|
||||
|
@ -116,6 +117,8 @@ class SavedAccount(
|
|||
var notification_status_reference by jsonDelegates.boolean
|
||||
|
||||
init {
|
||||
log.i("afterAccountVerify sa.ctor acctArg=$acctArg")
|
||||
|
||||
val tmpAcct = Acct.parse(acctArg)
|
||||
this.username = tmpAcct.username
|
||||
if (username.isEmpty()) error("missing username in acct")
|
||||
|
@ -229,15 +232,16 @@ class SavedAccount(
|
|||
}
|
||||
}
|
||||
|
||||
fun updateTokenInfo(tokenInfoArg: JsonObject?) {
|
||||
|
||||
fun updateTokenInfo(auth2Result: Auth2Result) {
|
||||
if (db_id == INVALID_DB_ID) error("updateTokenInfo: missing db_id")
|
||||
|
||||
val token_info = tokenInfoArg ?: JsonObject()
|
||||
this.token_info = token_info
|
||||
this.token_info = auth2Result.tokenJson
|
||||
this.loginAccount = auth2Result.tootAccount
|
||||
|
||||
ContentValues().apply {
|
||||
put(COL_TOKEN, token_info.toString())
|
||||
put(COL_TOKEN, auth2Result.tokenJson.toString())
|
||||
put(COL_ACCOUNT, auth2Result.accountJson.toString())
|
||||
put(COL_MISSKEY_VERSION, auth2Result.tootInstance.misskeyVersionMajor)
|
||||
}.let { appDatabase.update(table, it, "$COL_ID=?", arrayOf(db_id.toString())) }
|
||||
}
|
||||
|
||||
|
@ -290,27 +294,6 @@ class SavedAccount(
|
|||
}.let { appDatabase.update(table, it, "$COL_ID=?", arrayOf(db_id.toString())) }
|
||||
}
|
||||
|
||||
// fun saveNotificationTag() {
|
||||
// if(db_id == INVALID_DB_ID)
|
||||
// throw RuntimeException("SavedAccount.saveNotificationTag missing db_id")
|
||||
//
|
||||
// val cv = ContentValues()
|
||||
// cv.put(COL_NOTIFICATION_TAG, notification_tag)
|
||||
//
|
||||
// appDatabase.update(table, cv, "$COL_ID=?", arrayOf(db_id.toString()))
|
||||
// }
|
||||
//
|
||||
// fun saveRegisterKey() {
|
||||
// if(db_id == INVALID_DB_ID)
|
||||
// throw RuntimeException("SavedAccount.saveRegisterKey missing db_id")
|
||||
//
|
||||
// val cv = ContentValues()
|
||||
// cv.put(COL_REGISTER_KEY, register_key)
|
||||
// cv.put(COL_REGISTER_TIME, register_time)
|
||||
//
|
||||
// appDatabase.update(table, cv, "$COL_ID=?", arrayOf(db_id.toString()))
|
||||
// }
|
||||
|
||||
// onResumeの時に設定を読み直す
|
||||
fun reloadSetting(context: Context, newData: SavedAccount? = null) {
|
||||
|
||||
|
@ -686,7 +669,10 @@ class SavedAccount(
|
|||
return result
|
||||
}
|
||||
|
||||
fun loadAccountByAcct(context: Context, fullAcct: String): SavedAccount? {
|
||||
/**
|
||||
* acctを指定してアカウントを取得する
|
||||
*/
|
||||
fun loadAccountByAcct(context: Context, fullAcct: String) =
|
||||
try {
|
||||
appDatabase.query(
|
||||
table,
|
||||
|
@ -696,19 +682,14 @@ class SavedAccount(
|
|||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
.use { cursor ->
|
||||
if (cursor.moveToNext()) {
|
||||
return parse(context, cursor)
|
||||
}
|
||||
}
|
||||
).use { cursor ->
|
||||
if (cursor.moveToNext()) parse(context, cursor) else null
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
log.e(ex, "loadAccountByAcct failed.")
|
||||
null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun hasRealAccount(): Boolean {
|
||||
try {
|
||||
appDatabase.query(
|
||||
|
@ -846,7 +827,7 @@ class SavedAccount(
|
|||
}
|
||||
|
||||
val misskeyApiToken: String?
|
||||
get() = token_info?.string(TootApiClient.KEY_API_KEY_MISSKEY)
|
||||
get() = token_info?.string(AuthBase.KEY_API_KEY_MISSKEY)
|
||||
|
||||
fun putMisskeyApiToken(params: JsonObject = JsonObject()): JsonObject {
|
||||
val apiKey = misskeyApiToken
|
||||
|
@ -902,36 +883,42 @@ class SavedAccount(
|
|||
return myId != EntityId.CONFIRMING
|
||||
}
|
||||
|
||||
suspend fun checkConfirmed(context: Context, client: TootApiClient): TootApiResult? {
|
||||
try {
|
||||
val myId = this.loginAccount?.id
|
||||
if (db_id != INVALID_DB_ID && myId == EntityId.CONFIRMING) {
|
||||
val accessToken = getAccessToken()
|
||||
if (accessToken != null) {
|
||||
val result = client.getUserCredential(accessToken)
|
||||
if (result == null || result.error != null) return result
|
||||
val ta = TootParser(context, this).account(result.jsonObject)
|
||||
if (ta != null) {
|
||||
this.loginAccount = ta
|
||||
ContentValues().apply {
|
||||
put(COL_ACCOUNT, result.jsonObject.toString())
|
||||
}.let {
|
||||
appDatabase.update(
|
||||
table,
|
||||
it,
|
||||
"$COL_ID=?",
|
||||
arrayOf(db_id.toString())
|
||||
)
|
||||
}
|
||||
checkNotificationImmediateAll(context, onlySubscription = true)
|
||||
checkNotificationImmediate(context, db_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return TootApiResult()
|
||||
} catch (ex: Throwable) {
|
||||
log.e(ex, "account confirmation failed.")
|
||||
return TootApiResult(ex.withCaption("account confirmation failed."))
|
||||
/**
|
||||
* ユーザ登録の確認手順が完了しているかどうか
|
||||
*
|
||||
* - マストドン以外だと何もしないはず
|
||||
*/
|
||||
suspend fun checkConfirmed(context: Context, client: TootApiClient) {
|
||||
// 承認待ち状態ではないならチェックしない
|
||||
if (loginAccount?.id != EntityId.CONFIRMING) return
|
||||
|
||||
// DBに保存されていないならチェックしない
|
||||
if (db_id == INVALID_DB_ID) return
|
||||
|
||||
// アクセストークンがないならチェックしない
|
||||
val accessToken = getAccessToken()
|
||||
?: return
|
||||
|
||||
// ユーザ情報を取得してみる。承認済みなら読めるはず
|
||||
// 読めなければ例外が出る
|
||||
val userJson = client.getUserCredential(
|
||||
accessToken = accessToken,
|
||||
outTokenInfo = null,
|
||||
misskeyVersion = 0, // Mastodon only
|
||||
)
|
||||
// 読めたらアプリ内の記録を更新する
|
||||
TootParser(context, this).account(userJson)?.let { ta ->
|
||||
this.loginAccount = ta
|
||||
appDatabase.update(
|
||||
table,
|
||||
ContentValues().apply {
|
||||
put(COL_ACCOUNT, userJson.toString())
|
||||
},
|
||||
"$COL_ID=?",
|
||||
arrayOf(db_id.toString())
|
||||
)
|
||||
checkNotificationImmediateAll(context, onlySubscription = true)
|
||||
checkNotificationImmediate(context, db_id)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import jp.juggler.subwaytooter.R
|
|||
import jp.juggler.subwaytooter.api.TootApiCallback
|
||||
import jp.juggler.subwaytooter.api.TootApiClient
|
||||
import jp.juggler.subwaytooter.api.TootApiResult
|
||||
import jp.juggler.subwaytooter.api.auth.AuthBase
|
||||
import jp.juggler.subwaytooter.api.entity.*
|
||||
import jp.juggler.subwaytooter.api.runApiTask
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
|
@ -225,7 +226,7 @@ class AttachmentUploader(
|
|||
}
|
||||
val result = try {
|
||||
if (request.pa.isCancelled) continue
|
||||
withContext(request.pa.job + AppDispatchers.io) {
|
||||
withContext(request.pa.job + AppDispatchers.IO) {
|
||||
request.upload()
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
|
@ -233,7 +234,7 @@ class AttachmentUploader(
|
|||
}
|
||||
try {
|
||||
request.pa.progress = ""
|
||||
withContext(AppDispatchers.mainImmediate) {
|
||||
withContext(AppDispatchers.MainImmediate) {
|
||||
handleResult(request, result)
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
|
@ -373,7 +374,7 @@ class AttachmentUploader(
|
|||
val multipartBuilder = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
|
||||
val apiKey = account.token_info?.string(TootApiClient.KEY_API_KEY_MISSKEY)
|
||||
val apiKey = account.token_info?.string(AuthBase.KEY_API_KEY_MISSKEY)
|
||||
if (apiKey?.isNotEmpty() == true) {
|
||||
multipartBuilder.addFormDataPart("i", apiKey)
|
||||
}
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
package jp.juggler.subwaytooter.util
|
||||
|
||||
import jp.juggler.subwaytooter.api.entity.Acct
|
||||
import jp.juggler.subwaytooter.api.entity.Host
|
||||
import jp.juggler.subwaytooter.api.entity.HostAndDomain
|
||||
import jp.juggler.subwaytooter.api.entity.TootAccount
|
||||
import jp.juggler.subwaytooter.api.entity.*
|
||||
import jp.juggler.util.data.findOrNull
|
||||
import jp.juggler.util.data.groupEx
|
||||
|
||||
|
@ -41,6 +38,12 @@ interface LinkHelper : HostAndDomain {
|
|||
override val apDomain: Host = Host.UNKNOWN
|
||||
}
|
||||
|
||||
fun create(ti: TootInstance) = create(
|
||||
apiHostArg = ti.apiHost,
|
||||
apDomainArg = ti.apDomain,
|
||||
misskeyVersion = ti.misskeyVersionMajor
|
||||
)
|
||||
|
||||
fun create(apiHostArg: Host, apDomainArg: Host? = null, misskeyVersion: Int = 0) =
|
||||
object : LinkHelper {
|
||||
|
||||
|
|
|
@ -482,7 +482,7 @@ class PostImpl(
|
|||
// 全ての確認を終えたらバックグラウンドでの処理を開始する
|
||||
isPosting.set(true)
|
||||
return try {
|
||||
withContext(AppDispatchers.mainImmediate) {
|
||||
withContext(AppDispatchers.MainImmediate) {
|
||||
activity.runApiTask(
|
||||
account,
|
||||
progressSetup = { it.setCanceledOnTouchOutside(false) },
|
||||
|
|
|
@ -6,21 +6,19 @@ import jp.juggler.util.log.LogCategory
|
|||
import okhttp3.*
|
||||
import ru.gildor.coroutines.okhttp.await
|
||||
|
||||
// okhttpそのままだとモックしづらいので
|
||||
// リクエストを投げてレスポンスを得る部分をインタフェースにまとめる
|
||||
|
||||
/**
|
||||
* okhttpClientをラップしたクラス。
|
||||
* - モック用途
|
||||
* - onCallCreated ラムダ
|
||||
* - networkTracker.checkNetworkState
|
||||
*/
|
||||
interface SimpleHttpClient {
|
||||
|
||||
var onCallCreated: (Call) -> Unit
|
||||
|
||||
// fun getResponse(
|
||||
// request: Request,
|
||||
// tmpOkhttpClient: OkHttpClient? = null
|
||||
// ): Response
|
||||
|
||||
suspend fun getResponse(
|
||||
request: Request,
|
||||
tmpOkhttpClient: OkHttpClient? = null,
|
||||
overrideClient: OkHttpClient? = null,
|
||||
): Response
|
||||
|
||||
fun getWebSocket(
|
||||
|
@ -40,22 +38,12 @@ class SimpleHttpClientImpl(
|
|||
|
||||
override var onCallCreated: (Call) -> Unit = {}
|
||||
|
||||
// override fun getResponse(
|
||||
// request: Request,
|
||||
// tmpOkhttpClient: OkHttpClient?
|
||||
// ): Response {
|
||||
// App1.getAppState(context).networkTracker.checkNetworkState()
|
||||
// val call = (tmpOkhttpClient ?: this.okHttpClient).newCall(request)
|
||||
// onCallCreated(call)
|
||||
// return call.execute()
|
||||
// }
|
||||
|
||||
override suspend fun getResponse(
|
||||
request: Request,
|
||||
tmpOkhttpClient: OkHttpClient?,
|
||||
overrideClient: OkHttpClient?,
|
||||
): Response {
|
||||
App1.getAppState(context).networkTracker.checkNetworkState()
|
||||
val call = (tmpOkhttpClient ?: this.okHttpClient).newCall(request)
|
||||
val call = (overrideClient ?: this.okHttpClient).newCall(request)
|
||||
onCallCreated(call)
|
||||
return call.await()
|
||||
}
|
||||
|
|
|
@ -371,7 +371,7 @@
|
|||
<string name="video_capture">動画を記録</string>
|
||||
<string name="voice_capture">音声を記録</string>
|
||||
<string name="in_reply_to_id_conversion_failed">アカウント切り替えできません。in_reply_toのID変換に失敗しました。</string>
|
||||
<string name="input_access_token">アクセストークン</string>
|
||||
<string name="input_access_token">アクセストークンを入力</string>
|
||||
<string name="input_access_token_desc">通常のユーザ認証の代わりにアクセストークンを指定します。(上級者向け)</string>
|
||||
<string name="instance">サーバー</string>
|
||||
<string name="instance_does_not_support_push_api">サーバーのバージョン %1$s は古くてプッシュ購読を利用できません</string>
|
||||
|
|
|
@ -439,7 +439,7 @@
|
|||
<string name="delete_this_notification">Delete this notification</string>
|
||||
<string name="mute_this_conversation">Mute more notifications for this conversation</string>
|
||||
<string name="unmute_this_conversation">Unmute more notifications for this conversation</string>
|
||||
<string name="input_access_token">Access token</string>
|
||||
<string name="input_access_token">Enter access token</string>
|
||||
<string name="input_access_token_desc">Specify access token instead of normal user authentication. (advanced)</string>
|
||||
<string name="access_token_or_api_token">Access token (if using Misskey, please input API token)</string>
|
||||
<string name="token_not_specified">Please input access token.</string>
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
package jp.juggler.subwaytooter;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
@Test
|
||||
public void addition_isCorrect(){
|
||||
assertEquals( 4, 2 + 2 );
|
||||
}
|
||||
|
||||
/*
|
||||
test は開発環境側で行われて、実機が必要なAPIは"not mocked"と怒られて失敗する。
|
||||
SparseIntArray等を利用できない。
|
||||
|
||||
androidTest の方は実機かエミュレータで動作する
|
||||
*/
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.*
|
||||
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
|
@ -9,9 +9,9 @@ class TestColumnMeta {
|
|||
@Test
|
||||
fun test1() {
|
||||
val columnList = SavedAccount.columnList
|
||||
val actual = columnList.createTableSql().joinToString(";")
|
||||
val expect =
|
||||
"create table if not exists access_info (_id INTEGER PRIMARY KEY,a text not null,confirm_boost integer default 1,confirm_favourite integer default 1,confirm_follow integer default 1,confirm_follow_locked integer default 1,confirm_post integer default 1,confirm_reaction integer default 1,confirm_unboost integer default 1,confirm_unfavourite integer default 1,confirm_unfollow integer default 1,d text,default_sensitive integer default 0,default_text text default '',dont_hide_nsfw integer default 0,dont_show_timeout integer default 0,expand_cw integer default 0,h text not null,image_max_megabytes text default null,image_resize text default null,is_misskey integer default 0,last_notification_error text,last_push_endpoint text,last_subscription_error text,max_toot_chars integer default 0,movie_max_megabytes text default null,notification_boost integer default 1,notification_favourite integer default 1,notification_follow integer default 1,notification_follow_request integer default 1,notification_mention integer default 1,notification_post integer default 1,notification_reaction integer default 1,notification_server text default '',notification_vote integer default 1,push_policy text default null,register_key text default '',register_time integer default 0,sound_uri text default '',t text not null,u text not null,visibility text);create index if not exists access_info_user on access_info(u);create index if not exists access_info_host on access_info(h,u)"
|
||||
val actual = columnList.createTableSql()
|
||||
.joinToString(";")
|
||||
val expect ="create table if not exists access_info (_id INTEGER PRIMARY KEY,a text not null,confirm_boost integer default 1,confirm_favourite integer default 1,confirm_follow integer default 1,confirm_follow_locked integer default 1,confirm_post integer default 1,confirm_reaction integer default 1,confirm_unbookmark integer default 1,confirm_unboost integer default 1,confirm_unfavourite integer default 1,confirm_unfollow integer default 1,d text,default_sensitive integer default 0,default_text text default '',dont_hide_nsfw integer default 0,dont_show_timeout integer default 0,expand_cw integer default 0,extra_json text default null,h text not null,image_max_megabytes text default null,image_resize text default null,is_misskey integer default 0,last_notification_error text,last_push_endpoint text,last_subscription_error text,max_toot_chars integer default 0,movie_max_megabytes text default null,notification_boost integer default 1,notification_favourite integer default 1,notification_follow integer default 1,notification_follow_request integer default 1,notification_mention integer default 1,notification_post integer default 1,notification_reaction integer default 1,notification_server text default '',notification_update integer default 1,notification_vote integer default 1,push_policy text default null,register_key text default '',register_time integer default 0,sound_uri text default '',t text not null,u text not null,visibility text);create index if not exists access_info_user on access_info(u);create index if not exists access_info_host on access_info(h,u)"
|
||||
assertEquals("SavedAccount createParams()", expect, actual)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import jp.juggler.util.*
|
||||
import jp.juggler.util.data.*
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import java.io.StringWriter
|
||||
|
@ -84,13 +84,13 @@ class TestJson {
|
|||
assertEquals("\"Aa\"", En.Aa.encodeSimpleJsonValue(0))
|
||||
|
||||
// object
|
||||
val o = jsonObject {}
|
||||
val o = buildJsonObject {}
|
||||
assertEquals("{}", o.encodeSimpleJsonValue(0))
|
||||
o["a"] = "b"
|
||||
assertEquals("{\"a\":\"b\"}", o.encodeSimpleJsonValue(0))
|
||||
|
||||
// Collection
|
||||
val a = jsonArray {}
|
||||
val a = buildJsonArray {}
|
||||
assertEquals("[]", a.encodeSimpleJsonValue(0))
|
||||
a.add("b")
|
||||
assertEquals("[\"b\"]", a.encodeSimpleJsonValue(0))
|
||||
|
@ -133,32 +133,38 @@ class TestJson {
|
|||
|
||||
@Test
|
||||
fun testNumberEncode() {
|
||||
fun x(n: Number) {
|
||||
fun x(
|
||||
n: Number,
|
||||
expectValue:Number = n,
|
||||
expectClass:Class<*> = expectValue.javaClass,
|
||||
) {
|
||||
val encodedObject = jsonObjectOf("n" to n).toString()
|
||||
val decodedObject = encodedObject.decodeJsonObject()
|
||||
val decoded = decodedObject["n"]
|
||||
assertEquals("$n type $encodedObject", n.javaClass, decoded?.javaClass)
|
||||
assertEquals("$n value $encodedObject", n, decoded)
|
||||
assertEquals("$n type $encodedObject", expectClass, decoded?.javaClass)
|
||||
assertEquals("$n value $encodedObject", expectValue, decoded)
|
||||
}
|
||||
x(0)
|
||||
x(0f)
|
||||
x(0.0)
|
||||
x(0f ,expectValue = 0)
|
||||
x(0.0, expectValue = 0)
|
||||
x(-0)
|
||||
x(-0f)
|
||||
x(-0f, expectValue = -0.0)
|
||||
x(-0.0)
|
||||
x(0.5f)
|
||||
x(0.5f, expectValue = 0.5)
|
||||
x(0.5)
|
||||
x(-0.5f)
|
||||
x(-0.5f, expectValue = -0.5)
|
||||
x(-0.5)
|
||||
x(epsilon)
|
||||
x(Int.MIN_VALUE)
|
||||
x(Int.MAX_VALUE)
|
||||
x(Long.MIN_VALUE)
|
||||
x(Long.MAX_VALUE)
|
||||
x(Float.MAX_VALUE)
|
||||
x(Float.MIN_VALUE)
|
||||
x(Double.MAX_VALUE)
|
||||
x(Double.MIN_VALUE)
|
||||
|
||||
x(epsilon)
|
||||
// 誤差が出て上限/下限が合わないので、デコード時にはdouble解釈になる
|
||||
// x(Float.MAX_VALUE, expectValue = Float.MAX_VALUE.toDouble())
|
||||
// x(Float.MIN_VALUE, expectValue = Float.MIN_VALUE.toDouble())
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package jp.juggler.subwaytooter
|
||||
|
||||
import jp.juggler.subwaytooter.api.entity.TootAccount
|
||||
import jp.juggler.util.asciiPatternString
|
||||
import jp.juggler.util.data.asciiPatternString
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
|
|
|
@ -18,8 +18,8 @@ android {
|
|||
minSdk min_sdk_version
|
||||
targetSdk target_sdk_version
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles "consumer-rules.pro"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
@ -38,6 +38,9 @@ android {
|
|||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
packagingOptions {
|
||||
exclude("META-INF/LICENSE*")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
@ -105,11 +108,6 @@ dependencies {
|
|||
exclude group: "com.squareup.okhttp3", module: "okhttp"
|
||||
}
|
||||
|
||||
testApi "androidx.arch.core:core-testing:$arch_version"
|
||||
testApi "junit:junit:$junit_version"
|
||||
testApi "org.jetbrains.kotlin:kotlin-test"
|
||||
testApi "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_version"
|
||||
|
||||
androidTestApi "androidx.test.espresso:espresso-core:3.5.1"
|
||||
androidTestApi "androidx.test.ext:junit-ktx:1.1.5"
|
||||
androidTestApi "androidx.test.ext:junit:1.1.5"
|
||||
|
@ -119,6 +117,10 @@ dependencies {
|
|||
androidTestApi "androidx.test:runner:1.5.2"
|
||||
androidTestApi "org.jetbrains.kotlin:kotlin-test"
|
||||
androidTestApi "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_version"
|
||||
testApi "androidx.arch.core:core-testing:$arch_version"
|
||||
testApi "junit:junit:$junit_version"
|
||||
testApi "org.jetbrains.kotlin:kotlin-test"
|
||||
testApi "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_version"
|
||||
|
||||
// To use android test orchestrator
|
||||
androidTestUtil "androidx.test:orchestrator:1.4.2"
|
||||
|
|
|
@ -13,21 +13,22 @@ import org.junit.runner.Description
|
|||
/**
|
||||
* Dispatchers.Main のテスト中の置き換えを複数テストで衝突しないようにルール化する
|
||||
* https://developer.android.com/kotlin/coroutines/test?hl=ja
|
||||
*
|
||||
* junit5対応について
|
||||
* https://stackoverflow.com/questions/69423060/viewmodel-ui-testing-with-junit-5
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MainDispatcherRule(
|
||||
/**
|
||||
* UnconfinedTestDispatcher か StandardTestDispatcher のどちらかを指定する
|
||||
*/
|
||||
@ExperimentalCoroutinesApi
|
||||
class AppTestDispatcherRule(
|
||||
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
|
||||
) : TestWatcher() {
|
||||
|
||||
override fun starting(description: Description) {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
AppDispatchers.setTest(testDispatcher)
|
||||
}
|
||||
|
||||
override fun finished(description: Description) {
|
||||
AppDispatchers.reset()
|
||||
Dispatchers.resetMain()
|
||||
AppDispatchers.reset()
|
||||
}
|
||||
}
|
|
@ -8,9 +8,9 @@ import kotlinx.coroutines.*
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.test.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.*
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
|
@ -18,8 +18,8 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|||
* kotlinx.coroutines.test の使い方の説明
|
||||
* https://developer.android.com/kotlin/coroutines/test?hl=ja#testdispatchers
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DispatchersTest {
|
||||
|
||||
// 単純なリポジトリ
|
||||
|
@ -31,7 +31,7 @@ class DispatchersTest {
|
|||
|
||||
// Dispatcherを受け取るリポジトリ
|
||||
private class Repository(
|
||||
private val ioDispatcher: CoroutineDispatcher = AppDispatchers.io,
|
||||
private val ioDispatcher: CoroutineDispatcher = AppDispatchers.IO,
|
||||
) {
|
||||
private val ioScope = CoroutineScope(ioDispatcher)
|
||||
val initialized = AtomicBoolean(false)
|
||||
|
@ -50,15 +50,12 @@ class DispatchersTest {
|
|||
}
|
||||
}
|
||||
|
||||
//================================================================
|
||||
|
||||
// テスト毎に書くと複数テストで衝突するので、MainDispatcherRuleに任せる
|
||||
// プロパティは記述順に初期化されることに注意
|
||||
// プロパティの定義順序に注意
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule()
|
||||
val dispatcheRule = AppTestDispatcherRule()
|
||||
|
||||
// スケジューラを共有するリポジトリ
|
||||
private val repository = Repository(mainDispatcherRule.testDispatcher)
|
||||
// リポジトリのスケジューラを共有する
|
||||
private val repository = Repository(dispatcheRule.testDispatcher)
|
||||
|
||||
//====================================================
|
||||
// テストでの suspend 関数の呼び出し
|
||||
|
|
|
@ -15,22 +15,22 @@ import kotlinx.coroutines.*
|
|||
object AppDispatchers {
|
||||
|
||||
// Main と Main.immediate は Dispatchers.setMain 差し替えられる
|
||||
val mainImmediate get() = Dispatchers.Main.immediate
|
||||
val MainImmediate get() = Dispatchers.Main.immediate
|
||||
|
||||
var unconfined: CoroutineDispatcher = Dispatchers.Unconfined
|
||||
var default: CoroutineDispatcher = Dispatchers.Default
|
||||
var io: CoroutineDispatcher = Dispatchers.IO
|
||||
var Unconfined: CoroutineDispatcher = Dispatchers.Unconfined
|
||||
var DEFAULT: CoroutineDispatcher = Dispatchers.Default
|
||||
var IO: CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
fun reset() {
|
||||
unconfined = Dispatchers.Unconfined
|
||||
default = Dispatchers.Default
|
||||
io = Dispatchers.IO
|
||||
Unconfined = Dispatchers.Unconfined
|
||||
DEFAULT = Dispatchers.Default
|
||||
IO = Dispatchers.IO
|
||||
}
|
||||
|
||||
fun setTest(testDispatcher: CoroutineDispatcher) {
|
||||
unconfined = testDispatcher
|
||||
default = testDispatcher
|
||||
io = testDispatcher
|
||||
Unconfined = testDispatcher
|
||||
DEFAULT = testDispatcher
|
||||
IO = testDispatcher
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -39,7 +39,7 @@ object AppDispatchers {
|
|||
* https://stackoverflow.com/questions/70658926/how-to-use-kotlinx-coroutines-withtimeout-in-kotlinx-coroutines-test-runtest
|
||||
*/
|
||||
suspend fun <T> withTimeoutSafe(timeMillis: Long, block: suspend CoroutineScope.() -> T) =
|
||||
when (io) {
|
||||
when (IO) {
|
||||
Dispatchers.IO -> withTimeout(timeMillis, block)
|
||||
else -> coroutineScope { block() }
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ abstract class AsyncActivity : AppCompatActivity(), CoroutineScope {
|
|||
private lateinit var activityJob: Job
|
||||
|
||||
override val coroutineContext: CoroutineContext
|
||||
get() = activityJob + AppDispatchers.mainImmediate
|
||||
get() = activityJob + AppDispatchers.MainImmediate
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
activityJob = Job()
|
||||
|
@ -20,6 +20,6 @@ abstract class AsyncActivity : AppCompatActivity(), CoroutineScope {
|
|||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
(activityJob + AppDispatchers.default).cancel()
|
||||
(activityJob + AppDispatchers.DEFAULT).cancel()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ private val log = LogCategory("EmptyScope")
|
|||
// プロセスが生きてる間ずっと動いててほしいものや特にキャンセルのタイミングがないコルーチンでは使い続けたい
|
||||
object EmptyScope : CoroutineScope {
|
||||
override val coroutineContext: CoroutineContext
|
||||
get() = EmptyCoroutineContext + AppDispatchers.mainImmediate
|
||||
get() = EmptyCoroutineContext + AppDispatchers.MainImmediate
|
||||
}
|
||||
|
||||
// メインスレッド上で動作するコルーチンを起動して、終了を待たずにリターンする。
|
||||
|
@ -38,7 +38,7 @@ fun launchMain(block: suspend CoroutineScope.() -> Unit): Job =
|
|||
// Default Dispatcherで動作するコルーチンを起動して、終了を待たずにリターンする。
|
||||
// 起動されたアクティビティのライフサイクルに関わらず中断しない。
|
||||
fun launchDefault(block: suspend CoroutineScope.() -> Unit): Job =
|
||||
EmptyScope.launch(context = AppDispatchers.default) {
|
||||
EmptyScope.launch(context = AppDispatchers.DEFAULT) {
|
||||
try {
|
||||
block()
|
||||
} catch (ex: Throwable) {
|
||||
|
@ -49,7 +49,7 @@ fun launchDefault(block: suspend CoroutineScope.() -> Unit): Job =
|
|||
// IOスレッド上で動作するコルーチンを起動して、終了を待たずにリターンする。
|
||||
// 起動されたアクティビティのライフサイクルに関わらず中断しない。
|
||||
fun launchIO(block: suspend CoroutineScope.() -> Unit): Job =
|
||||
EmptyScope.launch(context = AppDispatchers.io) {
|
||||
EmptyScope.launch(context = AppDispatchers.IO) {
|
||||
try {
|
||||
block()
|
||||
} catch (ex: Throwable) {
|
||||
|
@ -100,7 +100,7 @@ fun <T : Any?> AppCompatActivity.launchProgress(
|
|||
log.e(ex, "launchProgress: preProc failed.")
|
||||
}
|
||||
val result = supervisorScope {
|
||||
val task = async(AppDispatchers.io) {
|
||||
val task = async(AppDispatchers.IO) {
|
||||
doInBackground(progress)
|
||||
}
|
||||
progress.setOnCancelListener { task.cancel() }
|
||||
|
|
|
@ -30,6 +30,11 @@ fun Long.notZero(): Long? = if (this != 0L) this else null
|
|||
fun Float.notZero(): Float? = if (this != 0f) this else null
|
||||
fun Double.notZero(): Double? = if (this != .0) this else null
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// boolean
|
||||
inline fun <T : Any?> Boolean.ifTrue(block: () -> T?) = if (this) block() else null
|
||||
inline fun <T : Any?> Boolean.ifFalse(block: () -> T?) = if (this) null else block()
|
||||
|
||||
// usage: boolean.truth() ?: fallback()
|
||||
// equivalent: if(this != 0 ) this else null
|
||||
// fun Boolean.truth() : Boolean? = if(this) this else null
|
||||
|
|
|
@ -279,6 +279,10 @@ fun String.digestSHA256Base64Url(): String {
|
|||
// Uri.encode(s:Nullable) だと nullチェックができないので、簡単なラッパーを用意する
|
||||
fun String.encodePercent(allow: String? = null): String = Uri.encode(this, allow)
|
||||
|
||||
// %HH エンコードした後に %20 を + に変換する
|
||||
fun String.encodePercentPlus(allow: String? = null): String =
|
||||
Uri.encode(this, allow).replace("""%20""".toRegex(),"+")
|
||||
|
||||
// replace + to %20, then decode it.
|
||||
fun String.decodePercent(): String =
|
||||
Uri.decode(replace("+", "%20"))
|
||||
|
|
|
@ -76,7 +76,7 @@ suspend fun transcodeVideo(
|
|||
resizeConfig: MovieResizeConfig,
|
||||
onProgress: (Float) -> Unit,
|
||||
): File = try {
|
||||
withContext(AppDispatchers.io) {
|
||||
withContext(AppDispatchers.IO) {
|
||||
if (!resizeConfig.isTranscodeRequired(info)) {
|
||||
log.i("transcodeVideo: isTranscodeRequired returns false.")
|
||||
return@withContext inFile
|
||||
|
@ -99,7 +99,7 @@ suspend fun transcodeVideo(
|
|||
val resultFile = FileInputStream(inFile).use { inStream ->
|
||||
// 進捗コールバックの発生頻度が多すぎるので間引く
|
||||
val progressChannel = Channel<Float>(capacity = Channel.CONFLATED)
|
||||
val progressSender = launch(AppDispatchers.mainImmediate) {
|
||||
val progressSender = launch(AppDispatchers.MainImmediate) {
|
||||
try {
|
||||
while (true) {
|
||||
onProgress(progressChannel.receive())
|
||||
|
|
|
@ -1,17 +1,11 @@
|
|||
package jp.juggler.base
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ buildscript {
|
|||
ext.startup_version = "1.1.1"
|
||||
ext.roomVersion = "2.5.0"
|
||||
ext.workVersion = "2.7.1"
|
||||
ext.glideVersion = "4.13.2"
|
||||
ext.glideVersion = "4.14.2"
|
||||
|
||||
ext.appcompat_version = "1.6.0"
|
||||
ext.lifecycle_version = "2.5.1"
|
||||
|
@ -23,7 +23,6 @@ buildscript {
|
|||
ext.kotlinx_coroutines_version = "1.6.4"
|
||||
|
||||
ext.anko_version = "0.10.8"
|
||||
|
||||
ext.junit_version = "4.13.2"
|
||||
|
||||
ext.detekt_version = "1.22.0"
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
package jp.juggler.apng.sample;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.InstrumentationRegistry;
|
||||
import androidx.test.runner.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
@Test
|
||||
public void useAppContext() throws Exception{
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getTargetContext();
|
||||
|
||||
assertEquals( "jp.juggler.apng.sample", appContext.getPackageName() );
|
||||
}
|
||||
}
|
|
@ -36,7 +36,7 @@ class ActList : AppCompatActivity(), CoroutineScope {
|
|||
private lateinit var activityJob: Job
|
||||
|
||||
override val coroutineContext: CoroutineContext
|
||||
get() = activityJob + AppDispatchers.mainImmediate
|
||||
get() = activityJob + AppDispatchers.MainImmediate
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
||||
|
@ -86,7 +86,7 @@ class ActList : AppCompatActivity(), CoroutineScope {
|
|||
}
|
||||
|
||||
private fun load() = launch {
|
||||
val list = withContext(AppDispatchers.io) {
|
||||
val list = withContext(AppDispatchers.IO) {
|
||||
// RawリソースのIDと名前の一覧
|
||||
R.raw::class.java.fields
|
||||
.mapNotNull { it.get(null) as? Int }
|
||||
|
@ -179,7 +179,7 @@ class ActList : AppCompatActivity(), CoroutineScope {
|
|||
try {
|
||||
lastJob?.cancelAndJoin()
|
||||
|
||||
val job = async(AppDispatchers.io) {
|
||||
val job = async(AppDispatchers.IO) {
|
||||
try {
|
||||
ApngFrames.parse(128) { resources?.openRawResource(resId) }
|
||||
} catch (ex: Throwable) {
|
||||
|
|
|
@ -59,7 +59,7 @@ class ActViewer : AsyncActivity() {
|
|||
launch {
|
||||
var apngFrames: ApngFrames? = null
|
||||
try {
|
||||
apngFrames = withContext(AppDispatchers.io) {
|
||||
apngFrames = withContext(AppDispatchers.IO) {
|
||||
try {
|
||||
ApngFrames.parse(
|
||||
1024,
|
||||
|
@ -99,7 +99,7 @@ class ActViewer : AsyncActivity() {
|
|||
private fun save(apngFrames: ApngFrames) {
|
||||
val title = this.title
|
||||
|
||||
launch(AppDispatchers.io) {
|
||||
launch(AppDispatchers.IO) {
|
||||
|
||||
//deprecated in Android 10 (API level 29)
|
||||
//val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
package jp.juggler.apng.sample;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception{
|
||||
assertEquals( 4, 2 + 2 );
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue