認証まわりのコードを色々変えた

This commit is contained in:
tateisu 2023-01-17 21:42:47 +09:00
parent 28aacd3a7f
commit 9d712e9cc7
70 changed files with 2689 additions and 1801 deletions

View File

@ -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"
}

View File

@ -45,4 +45,5 @@ dependencies {
implementation project(":base")
implementation "com.github.zjupure:webpdecoder:2.3.$glideVersion"
testImplementation 'junit:junit:4.12'
}

View File

@ -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() );
}
}

View File

@ -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 );
}
}

View File

@ -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"

View File

@ -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))

View File

@ -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()
}
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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)

View File

@ -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,
)
}
})
}

View File

@ -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)
}

View File

@ -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) {

View File

@ -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) {

View File

@ -71,7 +71,7 @@ private class TootTaskRunner(
try {
openProgress()
supervisorScope {
async(AppDispatchers.io) {
async(AppDispatchers.IO) {
backgroundBlock(context, client)
}.also {
task = it

View File

@ -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
)

View File

@ -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 {

View File

@ -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) {
}
}

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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
)
}

View File

@ -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()
}

View File

@ -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)
}
}
}

View File

@ -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,
)
}
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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
}

View File

@ -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!!")
}

View File

@ -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) {

View File

@ -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) {

View File

@ -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}")

View File

@ -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,

View File

@ -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
}

View File

@ -69,7 +69,7 @@ class DlgCreateAccount(
activity,
linkHelper = LinkHelper.create(
apiHost,
misskeyVersion = instanceInfo?.misskeyVersion ?: 0
misskeyVersion = instanceInfo?.misskeyVersionMajor ?: 0
)
).decodeHTML(
instanceInfo?.short_description?.notBlank()

View File

@ -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) {

View File

@ -41,7 +41,7 @@ object LoginForm {
instanceArg: String?,
onClickOk: (
dialog: Dialog,
instance: Host,
apiHost: Host,
action: Action,
) -> Unit,
) {

View File

@ -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

View File

@ -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,

View File

@ -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.")

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -482,7 +482,7 @@ class PostImpl(
// 全ての確認を終えたらバックグラウンドでの処理を開始する
isPosting.set(true)
return try {
withContext(AppDispatchers.mainImmediate) {
withContext(AppDispatchers.MainImmediate) {
activity.runApiTask(
account,
progressSetup = { it.setCanceledOnTouchOutside(false) },

View File

@ -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()
}

View File

@ -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>

View File

@ -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>

View File

@ -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 の方は実機かエミュレータで動作する
*/
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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())
}
}

View File

@ -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

View File

@ -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"

View File

@ -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()
}
}

View File

@ -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 関数の呼び出し

View File

@ -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() }
}

View File

@ -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()
}
}

View File

@ -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() }

View File

@ -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

View File

@ -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"))

View File

@ -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())

View File

@ -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)
}
}
}

View File

@ -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"

View File

@ -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() );
}
}

View File

@ -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) {

View File

@ -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)

View File

@ -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 );
}
}