1
0
mirror of https://github.com/tateisu/SubwayTooter synced 2025-02-07 23:58:44 +01:00

認証周りのコード整理、バグ修正

This commit is contained in:
tateisu 2023-01-18 13:59:35 +09:00
parent 9d712e9cc7
commit ea2ac889f1
29 changed files with 712 additions and 570 deletions

View File

@ -7,7 +7,7 @@ import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.entity.Host import jp.juggler.subwaytooter.api.entity.Host
import jp.juggler.subwaytooter.api.entity.TootInstance import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.testutil.MainDispatcherRule import jp.juggler.subwaytooter.testutil.TestDispatcherRule
import jp.juggler.subwaytooter.testutil.MockInterceptor import jp.juggler.subwaytooter.testutil.MockInterceptor
import jp.juggler.subwaytooter.util.SimpleHttpClientImpl import jp.juggler.subwaytooter.util.SimpleHttpClientImpl
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
@ -30,7 +30,7 @@ class TestTootInstance {
// テスト毎に書くと複数テストで衝突するので、MainDispatcherRuleに任せる // テスト毎に書くと複数テストで衝突するので、MainDispatcherRuleに任せる
// プロパティは記述順に初期化されることに注意 // プロパティは記述順に初期化されることに注意
@get:Rule @get:Rule
val mainDispatcherRule = MainDispatcherRule() val mainDispatcherRule = TestDispatcherRule()
private val client by lazy { private val client by lazy {
val mockInterceptor = MockInterceptor( val mockInterceptor = MockInterceptor(

View File

@ -11,7 +11,7 @@ import jp.juggler.subwaytooter.api.entity.Host
import jp.juggler.subwaytooter.api.entity.TootInstance import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.table.ClientInfo import jp.juggler.subwaytooter.table.ClientInfo
import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.testutil.MainDispatcherRule import jp.juggler.subwaytooter.testutil.TestDispatcherRule
import jp.juggler.subwaytooter.testutil.assertThrowsSuspend import jp.juggler.subwaytooter.testutil.assertThrowsSuspend
import jp.juggler.subwaytooter.util.SimpleHttpClient import jp.juggler.subwaytooter.util.SimpleHttpClient
import jp.juggler.util.data.* import jp.juggler.util.data.*
@ -36,7 +36,7 @@ class TestTootApiClient {
// テスト毎に書くと複数テストで衝突するので、MainDispatcherRuleに任せる // テスト毎に書くと複数テストで衝突するので、MainDispatcherRuleに任せる
// プロパティは記述順に初期化されることに注意 // プロパティは記述順に初期化されることに注意
@get:Rule @get:Rule
val mainDispatcherRule = MainDispatcherRule() val mainDispatcherRule = TestDispatcherRule()
companion object { companion object {
private val log = LogCategory("TestTootApiClient") private val log = LogCategory("TestTootApiClient")
@ -1069,7 +1069,7 @@ class TestTootApiClient {
val (ti, ri) = TootInstance.get(client) val (ti, ri) = TootInstance.get(client)
ti ?: error("can't get server information. ${ri?.error}") ti ?: error("can't get server information. ${ri?.error}")
val auth = AuthBase.findAuth(client, ti, ri) as MastodonAuth val auth = AuthBase.findAuthForAuthStep1(client, ti, ri) as MastodonAuth
val authUri = auth.authStep1(ti, forceUpdateClient = false) val authUri = auth.authStep1(ti, forceUpdateClient = false)
println("authUri=$authUri") println("authUri=$authUri")
@ -1189,8 +1189,7 @@ class TestTootApiClient {
} }
@Test @Test
fun testGetInstanceInformation() = fun testGetInstanceInformation() = runTest {
runTest {
val callback = ProgressRecordTootApiCallback() val callback = ProgressRecordTootApiCallback()
val client = TootApiClient( val client = TootApiClient(
appContext, appContext,

View File

@ -15,7 +15,7 @@ import org.junit.runner.Description
* https://developer.android.com/kotlin/coroutines/test?hl=ja * https://developer.android.com/kotlin/coroutines/test?hl=ja
*/ */
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule( class TestDispatcherRule(
/** /**
* UnconfinedTestDispatcher StandardTestDispatcher のどちらかを指定する * UnconfinedTestDispatcher StandardTestDispatcher のどちらかを指定する
*/ */

View File

@ -21,7 +21,7 @@ import androidx.core.content.ContextCompat
import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager.widget.ViewPager import androidx.viewpager.widget.ViewPager
import jp.juggler.subwaytooter.action.checkAccessToken2 import jp.juggler.subwaytooter.action.accessTokenPrompt
import jp.juggler.subwaytooter.action.timeline import jp.juggler.subwaytooter.action.timeline
import jp.juggler.subwaytooter.actmain.* import jp.juggler.subwaytooter.actmain.*
import jp.juggler.subwaytooter.actpost.CompletionHelper import jp.juggler.subwaytooter.actpost.CompletionHelper
@ -289,7 +289,8 @@ class ActMain : AppCompatActivity(),
ActAccountSetting.RESULT_INPUT_ACCESS_TOKEN -> ActAccountSetting.RESULT_INPUT_ACCESS_TOKEN ->
r.data?.long(ActAccountSetting.EXTRA_DB_ID) r.data?.long(ActAccountSetting.EXTRA_DB_ID)
?.let { checkAccessToken2(it) } ?.let { SavedAccount.loadAccount(this, it) }
?.let { accessTokenPrompt(it.apiHost) }
} }
} }

View File

@ -3,7 +3,8 @@ package jp.juggler.subwaytooter.action
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.runApiTask import jp.juggler.subwaytooter.api.runApiTask2
import jp.juggler.subwaytooter.api.showApiError
import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.UserRelation import jp.juggler.subwaytooter.table.UserRelation
import jp.juggler.subwaytooter.util.matchHost import jp.juggler.subwaytooter.util.matchHost
@ -24,14 +25,12 @@ internal suspend fun AppCompatActivity.addPseudoAccount(
try { try {
suspend fun AppCompatActivity.getInstanceInfo(): TootInstance? { suspend fun AppCompatActivity.getInstanceInfo(): TootInstance? {
var resultTi: TootInstance? = null return try {
val result = runApiTask(host) { client -> runApiTask2(host) { TootInstance.getOrThrow(it) }
val (instance, instanceResult) = TootInstance.get(client) } catch (ex: Throwable) {
resultTi = instance showApiError(ex)
instanceResult null
} }
result?.error?.let { showToast(true, it) }
return resultTi
} }
val acct = Acct.parse("?", host) val acct = Acct.parse("?", host)

View File

@ -7,23 +7,24 @@ import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.* import jp.juggler.subwaytooter.*
import jp.juggler.subwaytooter.actmain.addColumn import jp.juggler.subwaytooter.actmain.addColumn
import jp.juggler.subwaytooter.actmain.afterAccountVerify import jp.juggler.subwaytooter.actmain.afterAccountVerify
import jp.juggler.subwaytooter.actmain.defaultInsertPosition
import jp.juggler.subwaytooter.api.* import jp.juggler.subwaytooter.api.*
import jp.juggler.subwaytooter.api.auth.Auth2Result import jp.juggler.subwaytooter.api.auth.Auth2Result
import jp.juggler.subwaytooter.api.auth.MastodonAuth import jp.juggler.subwaytooter.api.auth.AuthBase
import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.column.ColumnType import jp.juggler.subwaytooter.column.ColumnType
import jp.juggler.subwaytooter.dialog.* import jp.juggler.subwaytooter.dialog.*
import jp.juggler.subwaytooter.dialog.DlgCreateAccount.Companion.showUserCreateDialog
import jp.juggler.subwaytooter.dialog.LoginForm.Companion.showLoginForm
import jp.juggler.subwaytooter.notification.APP_SERVER import jp.juggler.subwaytooter.notification.APP_SERVER
import jp.juggler.subwaytooter.pref.* import jp.juggler.subwaytooter.pref.*
import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.LinkHelper import jp.juggler.subwaytooter.util.LinkHelper
import jp.juggler.subwaytooter.util.openBrowser import jp.juggler.subwaytooter.util.openBrowser
import jp.juggler.util.* import jp.juggler.util.*
import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.coroutine.launchIO import jp.juggler.util.coroutine.launchIO
import jp.juggler.util.coroutine.launchMain import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.data.JsonObject import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.buildJsonObject
import jp.juggler.util.data.encodePercent import jp.juggler.util.data.encodePercent
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast import jp.juggler.util.log.showToast
@ -55,149 +56,33 @@ fun isAndroid7TlsBug(errorText: String) =
} }
} }
private fun ActMain.accountCreate( /**
apiHost: Host, * サイドメニューでアカウントの追加を選ぶと呼び出される
clientInfo: JsonObject, * - サーバ名とアクションを指定するダイアログを開く
dialogHost: Dialog, * - 選択されたアクションに応じて分岐する
) { */
val activity = this
DlgCreateAccount(
activity,
apiHost
) { dialog_create, username, email, password, agreement, reason ->
// dialog引数が二つあるのに注意
launchMain {
try {
val auth2Result = runApiTask2(apiHost) { client ->
// Mastodon限定
val misskeyVersion = 0 // TootInstance.parseMisskeyVersion(tokenJson)
val auth = MastodonAuth(client)
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()
}
// アカウントの追加
fun ActMain.accountAdd() { fun ActMain.accountAdd() {
val activity = this showLoginForm { dialogHost, apiHost, serverInfo, action ->
LoginForm.showLoginForm(this, null) { dialogHost, instance, action ->
launchMain { launchMain {
try { try {
when (action) { when (action) {
// ログイン画面を開く LoginForm.Action.Login -> {
LoginForm.Action.Existing -> val authUri = runApiTask2(apiHost) { it.authStep1() }
runApiTask2(instance) { client -> openBrowser(authUri)
val authUri = client.authStep1() dialogHost.dismissSafe()
withContext(AppDispatchers.MainImmediate) { }
openBrowser(authUri) LoginForm.Action.Pseudo -> {
dialogHost.dismissSafe() val tootInstance = runApiTask2(apiHost) { TootInstance.getOrThrow(it) }
} addPseudoAccount(apiHost, tootInstance)?.let { a ->
showToast(false, R.string.server_confirmed)
addColumn(defaultInsertPosition, a, ColumnType.LOCAL)
dialogHost.dismissSafe()
} }
}
// ユーザ作成
LoginForm.Action.Create -> LoginForm.Action.Create ->
runApiTask2(instance) { client -> createUser(apiHost, serverInfo) { dialogHost.dismissSafe() }
val clientInfo = client.prepareClient()
withContext(AppDispatchers.MainImmediate) {
accountCreate(instance, clientInfo, dialogHost)
}
}
// 疑似アカウント
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()
}
}
}
LoginForm.Action.Token -> LoginForm.Action.Token ->
runApiTask2(instance) { client -> accessTokenPrompt(apiHost) { dialogHost.dismissSafe() }
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) { } catch (ex: Throwable) {
showApiError(ex) showApiError(ex)
@ -206,6 +91,110 @@ fun ActMain.accountAdd() {
} }
} }
private suspend fun ActMain.createUser(
apiHost: Host,
serverInfo: TootInstance?,
onComplete: (() -> Unit)? = null,
) {
serverInfo ?: error(
getString(
R.string.user_creation_not_supported,
apiHost.pretty,
"(unknown)",
)
)
fun TootApiClient.authUserCreate() =
AuthBase.findAuthForCreateUser(this, serverInfo)
?: error(
getString(
R.string.user_creation_not_supported,
apiHost.pretty,
serverInfo.instanceType.toString(),
)
)
// クライアント情報を取得。サーバ種別によってはユーザ作成ができないのでエラーとなる
val clientInfo = runApiTask2(apiHost) {
it.authUserCreate().prepareClient(serverInfo)
}
showUserCreateDialog(apiHost) { dialogCreate, params ->
launchMain {
try {
val auth2Result = runApiTask2(apiHost) {
it.authUserCreate().createUser(clientInfo = clientInfo, params = params)
}
if (afterAccountVerify(auth2Result)) {
dialogCreate.dismissSafe()
onComplete?.invoke()
}
} catch (ex: Throwable) {
showApiError(ex)
}
}
}
}
/**
* アクセストークンを手動入力する
*
* @param apiHost アクセストークンと関係のあるサーバのAPIホスト
* @param onComplete 非nullならアカウント認証が終わったタイミングで呼ばれる
*/
// アクセストークンの手動入力(更新)
fun ActMain.accessTokenPrompt(
apiHost: Host,
onComplete: (() -> Unit)? = null,
) {
DlgTextInput.show(
this,
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) {
launchMain {
try {
val accessToken = text.trim()
val auth2Result = runApiTask2(apiHost) { client ->
val ti =
TootInstance.getExOrThrow(client, forceAccessToken = accessToken)
val tokenJson = JsonObject()
val userJson = client.verifyAccount(
accessToken,
outTokenInfo = tokenJson, // 更新される
misskeyVersion = ti.misskeyVersionMajor
)
val parser = TootParser(this, linkHelper = LinkHelper.create(ti))
Auth2Result(
tootInstance = ti,
tokenJson = tokenJson,
accountJson = userJson,
tootAccount = parser.account(userJson)
?: error("can't parse user information."),
)
}
if (afterAccountVerify(auth2Result)) {
dialog.dismissSafe()
onComplete?.invoke()
}
} catch (ex: Throwable) {
showApiError(ex)
}
}
}
}
)
}
fun AppCompatActivity.accountRemove(account: SavedAccount) { fun AppCompatActivity.accountRemove(account: SavedAccount) {
// if account is default account of tablet mode, // if account is default account of tablet mode,
// reset default. // reset default.
@ -397,71 +386,3 @@ suspend fun ActMain.accountListCanSeeMyReactions(pickupHost: Host? = null) =
} }
} }
} }
// アクセストークンを手動で入力した場合
fun ActMain.checkAccessToken(
dialogHost: Dialog?,
dialogToken: Dialog?,
apiHost: Host,
accessToken: String,
) {
launchMain {
try {
val auth2Result = runApiTask2(apiHost) { client ->
val (ti, ri) = TootInstance.getEx(client, forceAccessToken = accessToken)
ti ?: error("missing uri in Instance Information. ${ri?.error}")
val tokenJson = JsonObject()
val userJson = client.getUserCredential(
accessToken,
outTokenInfo = tokenJson, // 更新される
misskeyVersion = ti.misskeyVersionMajor
)
val parser = TootParser(this, linkHelper = LinkHelper.create(ti))
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 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 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

@ -5,12 +5,15 @@ import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.ApiTask import jp.juggler.subwaytooter.api.ApiTask
import jp.juggler.subwaytooter.api.entity.TootAccount import jp.juggler.subwaytooter.api.entity.TootAccount
import jp.juggler.subwaytooter.api.entity.TootInstance import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.api.runApiTask import jp.juggler.subwaytooter.api.runApiTask2
import jp.juggler.subwaytooter.util.EmojiDecoder import jp.juggler.subwaytooter.util.EmojiDecoder
import jp.juggler.util.coroutine.launchMain import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.data.wrapWeakReference import jp.juggler.util.data.wrapWeakReference
import jp.juggler.util.log.LogCategory
import jp.juggler.util.ui.attrColor import jp.juggler.util.ui.attrColor
private val log = LogCategory("ActPostCharCount")
// 最大文字数を取得する // 最大文字数を取得する
// 暫定で仮の値を返すことがある // 暫定で仮の値を返すことがある
// 裏で取得し終わったら updateTextCount() を呼び出す // 裏で取得し終わったら updateTextCount() を呼び出す
@ -25,17 +28,17 @@ private fun ActPost.getMaxCharCount(): Int {
// 同時に実行するタスクは1つまで // 同時に実行するタスクは1つまで
if (jobMaxCharCount?.get()?.isActive != true) { if (jobMaxCharCount?.get()?.isActive != true) {
jobMaxCharCount = launchMain { jobMaxCharCount = launchMain {
var newInfo: TootInstance? = null try {
runApiTask(account, progressStyle = ApiTask.PROGRESS_NONE) { client -> runApiTask2(account, progressStyle = ApiTask.PROGRESS_NONE) {
val (ti, result) = TootInstance.get(client) TootInstance.getOrThrow(it)
newInfo = ti }
result if (isFinishing || isDestroyed) return@launchMain
updateTextCount()
} catch (ex: Throwable) {
log.w(ex, "getMaxCharCount failed.")
} }
if (isFinishing || isDestroyed) return@launchMain
if (newInfo != null) updateTextCount()
}.wrapWeakReference }.wrapWeakReference
} }
// fall thru // fall thru
} }

View File

@ -430,35 +430,21 @@ class TootApiClient(
forceUpdateClient: Boolean = false, forceUpdateClient: Boolean = false,
): Uri { ): Uri {
val (ti, ri) = TootInstance.get(this) val (ti, ri) = TootInstance.get(this)
// 情報が取れなくても続ける
log.i("authentication1: instance info version=${ti?.version} misskeyVersion=${ti?.misskeyVersionMajor} responseCode=${ri?.response?.code}") log.i("authentication1: instance info version=${ti?.version} misskeyVersion=${ti?.misskeyVersionMajor} responseCode=${ri?.response?.code}")
return when (val auth = AuthBase.findAuth(this, ti, ri)) { return when (val auth = AuthBase.findAuthForAuthStep1(this, ti, ri)) {
null -> error("can't get server information. ${ri?.error}") null -> error("can't get server information. ${ri?.error}")
else -> auth.authStep1(ti, forceUpdateClient) else -> auth.authStep1(ti, forceUpdateClient)
} }
} }
suspend fun getUserCredential( suspend fun verifyAccount(
accessToken: String, accessToken: String,
outTokenInfo: JsonObject?, outTokenInfo: JsonObject?,
misskeyVersion: Int = 0, misskeyVersion: Int = 0,
) = AuthBase.findAuthForUserCredentian(this, misskeyVersion) ) = AuthBase.findAuthForVerifyAccount(this, misskeyVersion)
.verifyAccount(accessToken, outTokenInfo, misskeyVersion) .verifyAccount(accessToken, outTokenInfo, misskeyVersion)
/**
* サーバにクライアントアプリを登録する
* - Mastodonのユーザ作成で呼ばれる
*
* @return clientInfo
*/
suspend fun prepareClient(): JsonObject {
val (ti, ri) = TootInstance.get(this)
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)
}
}
//////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////
// JSONデータ以外を扱うリクエスト // JSONデータ以外を扱うリクエスト

View File

@ -2,7 +2,6 @@ package jp.juggler.subwaytooter.api.auth
import jp.juggler.subwaytooter.api.entity.TootAccount import jp.juggler.subwaytooter.api.entity.TootAccount
import jp.juggler.subwaytooter.api.entity.TootInstance import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.data.JsonObject import jp.juggler.util.data.JsonObject
/** /**
@ -13,14 +12,14 @@ class Auth2Result(
// サーバ情報 // サーバ情報
val tootInstance: TootInstance, val tootInstance: TootInstance,
// アクセストークンを含むJsonObject
val tokenJson: JsonObject,
// TootAccountユーザ情報の元となるJSONデータ // TootAccountユーザ情報の元となるJSONデータ
val accountJson: JsonObject, val accountJson: JsonObject,
// AccountJsonのパース結果 // AccountJsonのパース結果
val tootAccount: TootAccount, val tootAccount: TootAccount,
// アクセストークンを含むJsonObject
val tokenJson: JsonObject,
) { ) {
// 対象サーバのAPIホスト // 対象サーバのAPIホスト
val apiHost get() = tootInstance.apiHost val apiHost get() = tootInstance.apiHost

View File

@ -38,44 +38,34 @@ abstract class AuthBase {
).firstNotNullOfOrNull { it.notBlank() } ).firstNotNullOfOrNull { it.notBlank() }
?: DEFAULT_CLIENT_NAME ?: DEFAULT_CLIENT_NAME
fun findAuth(client: TootApiClient, ti: TootInstance?, ri: TootApiResult?): AuthBase? = fun findAuthForVerifyAccount(client: TootApiClient, misskeyVersionMajor: Int) =
when { when {
// インスタンス情報を取得できない misskeyVersionMajor >= 13 -> MisskeyAuth13(client)
ti == null -> when (ri?.response?.code) { misskeyVersionMajor > 0 -> MisskeyAuth10(client)
else -> MastodonAuth(client)
}
fun findAuthForAuthStep1(client: TootApiClient, ti: TootInstance?, ri: TootApiResult?) =
ti?.let { findAuthForVerifyAccount(client, ti.misskeyVersionMajor) }
?: when (ri?.response?.code) {
// インスタンス情報を取得できないが、マストドンだと分かる場合がある
// https://github.com/tateisu/SubwayTooter/issues/155 // https://github.com/tateisu/SubwayTooter/issues/155
// Mastodon's WHITELIST_MODE // Mastodon's WHITELIST_MODE
401 -> MastodonAuth(client) 401 -> MastodonAuth(client)
else -> null 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 = fun findAuthForAuthCallback(client: TootApiClient, callbackUrl: String) =
when { when {
MisskeyAuth10.isCallbackUrl(callbackUrl) -> MisskeyAuth10(client) MisskeyAuth10.isCallbackUrl(callbackUrl) -> MisskeyAuth10(client)
MisskeyAuth13.isCallbackUrl(callbackUrl) -> MisskeyAuth13(client) MisskeyAuth13.isCallbackUrl(callbackUrl) -> MisskeyAuth13(client)
else -> MastodonAuth(client) else -> MastodonAuth(client)
} }
fun findAuthForUserCredentian(client: TootApiClient, misskeyVersion: Int) = fun findAuthForCreateUser(client: TootApiClient, ti: TootInstance?) =
when { when (ti?.instanceType) {
misskeyVersion >= 13 -> MisskeyAuth13(client) InstanceType.Mastodon -> MastodonAuth(client)
misskeyVersion > 0 -> MisskeyAuth10(client) else -> null
else -> MastodonAuth(client)
}
fun findAuthForCreateUser(client: TootApiClient, ti: TootInstance) =
when (ti.instanceType) {
InstanceType.Misskey -> null
InstanceType.Pleroma -> null
InstanceType.Pixelfed -> null
else -> MastodonAuth(client)
} }
} }
@ -85,7 +75,6 @@ abstract class AuthBase {
protected val account get() = client.account protected val account get() = client.account
protected val context get() = client.context protected val context get() = client.context
/** /**
* クライアントを登録してブラウザで開くURLを生成する * クライアントを登録してブラウザで開くURLを生成する
* 成功したら TootApiResult.data にURL文字列を格納すること * 成功したら TootApiResult.data にURL文字列を格納すること

View File

@ -0,0 +1,9 @@
package jp.juggler.subwaytooter.api.auth
class CreateUserParams(
val username: String,
val email: String,
val password: String,
val agreement: Boolean,
val reason: String?,
)

View File

@ -4,6 +4,7 @@ import android.net.Uri
import jp.juggler.subwaytooter.api.SendException import jp.juggler.subwaytooter.api.SendException
import jp.juggler.subwaytooter.api.TootApiClient import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootParser 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.Host
import jp.juggler.subwaytooter.api.entity.InstanceType import jp.juggler.subwaytooter.api.entity.InstanceType
import jp.juggler.subwaytooter.api.entity.TootInstance import jp.juggler.subwaytooter.api.entity.TootInstance
@ -11,6 +12,7 @@ import jp.juggler.subwaytooter.table.ClientInfo
import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.LinkHelper import jp.juggler.subwaytooter.util.LinkHelper
import jp.juggler.util.data.JsonObject import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.buildJsonObject
import jp.juggler.util.data.notEmpty import jp.juggler.util.data.notEmpty
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.errorEx import jp.juggler.util.log.errorEx
@ -233,11 +235,10 @@ class MastodonAuth(override val client: TootApiClient) : AuthBase() {
// ?error=access_denied // ?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 // &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 // &state=db%3A3
arrayOf( arrayOf("error_description", "error")
uri.getQueryParameter("error")?.trim()?.notEmpty(), .mapNotNull { uri.getQueryParameter(it)?.trim()?.notEmpty() }
uri.getQueryParameter("error_description")?.trim()?.notEmpty() .notEmpty()
).filterNotNull().joinToString(" ") ?.let { error(it.joinToString("\n")) }
.notEmpty()?.let { error(it) }
// subwaytooter://oauth(\d*)/ // subwaytooter://oauth(\d*)/
// ?code=113cc036e078ac500d3d0d3ad345cd8181456ab087abc67270d40f40a4e9e3c2 // ?code=113cc036e078ac500d3d0d3ad345cd8181456ab087abc67270d40f40a4e9e3c2
@ -296,16 +297,14 @@ class MastodonAuth(override val client: TootApiClient) : AuthBase() {
misskeyVersion = 0 misskeyVersion = 0
) )
val (ti, ri) = TootInstance.getEx(client, forceAccessToken = accessToken) val ti = TootInstance.getExOrThrow(client, forceAccessToken = accessToken)
ti ?: error("can't get server information. ${ri?.error}") val parser = TootParser(context, linkHelper = LinkHelper.create(ti))
return Auth2Result( return Auth2Result(
tootInstance = ti, tootInstance = ti,
accountJson = accountJson,
tokenJson = tokenInfo, tokenJson = tokenInfo,
tootAccount = TootParser(context, linkHelper = LinkHelper.create(ti)) accountJson = accountJson,
.account(accountJson) tootAccount = parser.account(accountJson)
?: error("can't parse user information."), ?: error("can't parse user information.")
) )
} }
@ -322,26 +321,48 @@ class MastodonAuth(override val client: TootApiClient) : AuthBase() {
forceUpdateClient = false forceUpdateClient = false
) )
/**
* ユーザ作成
* - クライアントは登録済みであること
*/
suspend fun createUser( suspend fun createUser(
clientInfo: JsonObject, clientInfo: JsonObject,
username: String, params: CreateUserParams,
email: String, ): Auth2Result {
password: String, val apiHost = apiHost ?: error("createUser: missing apiHost")
agreement: Boolean,
reason: String?, val tokenJson = api.createUser(
) = api.createUser( apiHost = apiHost,
apiHost = apiHost ?: error("createUser: missing apiHost"), clientCredential = clientInfo.string(KEY_CLIENT_CREDENTIAL)
clientCredential = clientInfo.string(KEY_CLIENT_CREDENTIAL) ?: error("createUser: missing client credential"),
?: error("createUser: missing client credential"), params = params,
username = username, )
email = email,
password = password, val accessToken = tokenJson.string("access_token")
agreement = agreement, ?: error("can't get user access token")
reason = reason
) val ti = TootInstance.getExOrThrow(client, forceAccessToken = accessToken)
val parser = TootParser(context, linkHelper = LinkHelper.create(ti))
val accountJson = try {
verifyAccount(
accessToken = accessToken,
outTokenJson = tokenJson,
misskeyVersion = 0, // Mastodon限定
)
// メール確認が不要な場合は成功する
} catch (ex: Throwable) {
// メール確認がまだなら、verifyAccount は失敗する
log.e(ex, "createUser: can't verify account.")
buildJsonObject {
put("id", EntityId.CONFIRMING.toString())
put("username", params.username)
put("acct", params.username)
put("url", "https://$apiHost/@${params.username}")
}
}
return Auth2Result(
tootInstance = ti,
tokenJson = tokenJson,
accountJson = accountJson,
tootAccount = parser.account(accountJson)
?: error("can't verify user information."),
)
}
} }

View File

@ -151,17 +151,13 @@ class MastodonAuthApi(
suspend fun createUser( suspend fun createUser(
apiHost: Host, apiHost: Host,
clientCredential: String, clientCredential: String,
username: String, params: CreateUserParams,
email: String,
password: String,
agreement: Boolean,
reason: String?,
) = buildJsonObject { ) = buildJsonObject {
put("username", username) put("username", params.username)
put("email", email) put("email", params.email)
put("password", password) put("password", params.password)
put("agreement", agreement) put("agreement", params.agreement)
reason?.notEmpty()?.let { put("reason", reason) } params.reason?.notEmpty()?.let { put("reason", it) }
}.encodeQuery().toFormRequestBody().toPost() }.encodeQuery().toFormRequestBody().toPost()
.url("https://${apiHost.ascii}/api/v1/accounts") .url("https://${apiHost.ascii}/api/v1/accounts")
.header("Authorization", "Bearer $clientCredential") .header("Authorization", "Bearer $clientCredential")

View File

@ -201,8 +201,7 @@ class MisskeyAuth10(override val client: TootApiClient) : AuthBase() {
} ?: error("missing account db_id=$dbId") } ?: error("missing account db_id=$dbId")
} }
val (ti, r2) = TootInstance.get(client) val ti = TootInstance.getOrThrow(client)
ti ?: error("${r2?.error} ($apiHost)")
val parser = TootParser( val parser = TootParser(
context, context,
@ -230,14 +229,10 @@ class MisskeyAuth10(override val client: TootApiClient) : AuthBase() {
val accountJson = tokenInfo["user"].cast<JsonObject>() val accountJson = tokenInfo["user"].cast<JsonObject>()
?: error("missing user in the userkey response.") ?: error("missing user in the userkey response.")
val tootAccount = parser.account(accountJson)
?: error("can't parse user information")
tokenInfo.remove("user") tokenInfo.remove("user")
return Auth2Result( return Auth2Result(
tootInstance = ti, tootInstance = ti,
accountJson = accountJson,
tokenJson = tokenInfo.also { tokenJson = tokenInfo.also {
EntityId.mayNull(accountJson.string("id"))?.putTo(it, KEY_USER_ID) EntityId.mayNull(accountJson.string("id"))?.putTo(it, KEY_USER_ID)
it[KEY_MISSKEY_VERSION] = ti.misskeyVersionMajor it[KEY_MISSKEY_VERSION] = ti.misskeyVersionMajor
@ -245,7 +240,9 @@ class MisskeyAuth10(override val client: TootApiClient) : AuthBase() {
val apiKey = "$accessToken$appSecret".encodeUTF8().digestSHA256().encodeHexLower() val apiKey = "$accessToken$appSecret".encodeUTF8().digestSHA256().encodeHexLower()
it[KEY_API_KEY_MISSKEY] = apiKey it[KEY_API_KEY_MISSKEY] = apiKey
}, },
tootAccount = tootAccount, accountJson = accountJson,
tootAccount = parser.account(accountJson)
?: error("can't parse user information"),
) )
} }

View File

@ -109,8 +109,7 @@ class MisskeyAuth13(override val client: TootApiClient) : AuthBase() {
error("auth session id not match.") error("auth session id not match.")
} }
val (ti, r2) = TootInstance.get(client) val ti = TootInstance.getOrThrow(client)
ti ?: error("missing server information. ${r2?.error}")
val misskeyVersion = ti.misskeyVersionMajor val misskeyVersion = ti.misskeyVersionMajor
@ -136,13 +135,13 @@ class MisskeyAuth13(override val client: TootApiClient) : AuthBase() {
return Auth2Result( return Auth2Result(
tootInstance = ti, tootInstance = ti,
accountJson = accountJson,
tokenJson = JsonObject().apply { tokenJson = JsonObject().apply {
user.id.putTo(this, KEY_USER_ID) user.id.putTo(this, KEY_USER_ID)
put(KEY_MISSKEY_VERSION, misskeyVersion) put(KEY_MISSKEY_VERSION, misskeyVersion)
put(KEY_AUTH_VERSION, AUTH_VERSION) put(KEY_AUTH_VERSION, AUTH_VERSION)
put(KEY_API_KEY_MISSKEY, apiKey) put(KEY_API_KEY_MISSKEY, apiKey)
}, },
accountJson = accountJson,
tootAccount = user, tootAccount = user,
) )
} }

View File

@ -480,7 +480,32 @@ class TootInstance(parser: TootParser, src: JsonObject) {
fun getCached(apiHost: Host) = apiHost.getCacheEntry().cacheData fun getCached(apiHost: Host) = apiHost.getCacheEntry().cacheData
fun getCached(a: SavedAccount?) = a?.apiHost?.getCacheEntry()?.cacheData fun getCached(a: SavedAccount?) = a?.apiHost?.getCacheEntry()?.cacheData
suspend fun get(client: TootApiClient): Pair<TootInstance?, TootApiResult?> = getEx(client) suspend fun get(client: TootApiClient): Pair<TootInstance?, TootApiResult?> =
getEx(client)
suspend fun getOrThrow(client: TootApiClient): TootInstance {
val (ti, ri) = get(client)
return ti ?: error("can't get server information. ${ri?.error}")
}
suspend fun getExOrThrow(
client: TootApiClient,
hostArg: Host? = null,
account: SavedAccount? = null,
allowPixelfed: Boolean = false,
forceUpdate: Boolean = false,
forceAccessToken: String? = null, // マストドンのwhitelist modeでアカウント追加時に必要
): TootInstance {
val (ti, ri) = getEx(
client = client,
hostArg = hostArg,
account = account,
allowPixelfed = allowPixelfed,
forceUpdate = forceUpdate,
forceAccessToken = forceAccessToken,
)
return ti ?: error("can't get server information. ${ri?.error}")
}
suspend fun getEx( suspend fun getEx(
client: TootApiClient, client: TootApiClient,

View File

@ -10,6 +10,7 @@ import android.widget.EditText
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.auth.CreateUserParams
import jp.juggler.subwaytooter.api.entity.Host import jp.juggler.subwaytooter.api.entity.Host
import jp.juggler.subwaytooter.api.entity.TootInstance import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.util.DecodeOptions import jp.juggler.subwaytooter.util.DecodeOptions
@ -22,18 +23,16 @@ import jp.juggler.util.ui.*
class DlgCreateAccount( class DlgCreateAccount(
val activity: AppCompatActivity, val activity: AppCompatActivity,
val apiHost: Host, val apiHost: Host,
val onClickOk: ( val onClickOk: (dialog: Dialog, params: CreateUserParams) -> Unit,
dialog: Dialog,
username: String,
email: String,
password: String,
agreement: Boolean,
reason: String?,
) -> Unit,
) : View.OnClickListener { ) : View.OnClickListener {
companion object { companion object {
// private val log = LogCategory("DlgCreateAccount") // private val log = LogCategory("DlgCreateAccount")
fun AppCompatActivity.showUserCreateDialog(
apiHost: Host,
onClickOk: (dialog: Dialog, params: CreateUserParams) -> Unit,
) = DlgCreateAccount(this, apiHost, onClickOk).show()
} }
@SuppressLint("InflateParams") @SuppressLint("InflateParams")
@ -123,14 +122,16 @@ class DlgCreateAccount(
else -> onClickOk( else -> onClickOk(
dialog, dialog,
username, CreateUserParams(
email, username = username,
password, email = email,
cbAgreement.isChecked, password = password,
when (etReason.visibility) { agreement = cbAgreement.isChecked,
View.VISIBLE -> etReason.text.toString().trim() reason = when (etReason.visibility) {
else -> null View.VISIBLE -> etReason.text.toString().trim()
} else -> null
},
)
) )
} }
} }

View File

@ -1,25 +1,56 @@
package jp.juggler.subwaytooter.dialog package jp.juggler.subwaytooter.dialog
import android.annotation.SuppressLint
import android.app.Activity
import android.app.Dialog import android.app.Dialog
import android.text.InputType
import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.widget.* import android.widget.*
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.widget.addTextChangedListener
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.Host import jp.juggler.subwaytooter.api.entity.Host
import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.api.runApiTask2
import jp.juggler.subwaytooter.databinding.DlgAccountAddBinding
import jp.juggler.subwaytooter.databinding.LvAuthTypeBinding
import jp.juggler.subwaytooter.util.DecodeOptions
import jp.juggler.subwaytooter.util.LinkHelper
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.data.notBlank
import jp.juggler.util.data.notEmpty
import jp.juggler.util.log.* import jp.juggler.util.log.*
import jp.juggler.util.ui.*
import org.jetbrains.anko.textColor
import org.jetbrains.anko.textResource
import java.io.BufferedReader import java.io.BufferedReader
import java.io.InputStreamReader import java.io.InputStreamReader
import java.net.IDN import java.net.IDN
import java.util.* import java.util.*
object LoginForm { class LoginForm(
val activity: AppCompatActivity,
val onClickOk: (
dialog: Dialog,
apiHost: Host,
serverInfo: TootInstance?,
action: Action,
) -> Unit,
) {
companion object {
private val log = LogCategory("LoginForm")
private val log = LogCategory("LoginForm") @Suppress("RegExpSimplifiable")
val reBadLetter = """[^A-Za-z0-9:;._-]+""".toRegex()
fun AppCompatActivity.showLoginForm(
onClickOk: (
dialog: Dialog,
apiHost: Host,
serverInfo: TootInstance?,
action: Action,
) -> Unit,
) = LoginForm(this, onClickOk)
}
private class StringArray : ArrayList<String>() private class StringArray : ArrayList<String>()
@ -28,103 +59,51 @@ object LoginForm {
@StringRes val idName: Int, @StringRes val idName: Int,
@StringRes val idDesc: Int, @StringRes val idDesc: Int,
) { ) {
Login(0, R.string.existing_account, R.string.existing_account_desc),
Existing(0, R.string.existing_account, R.string.existing_account_desc),
Pseudo(1, R.string.pseudo_account, R.string.pseudo_account_desc), Pseudo(1, R.string.pseudo_account, R.string.pseudo_account_desc),
Create(2, R.string.create_account, R.string.create_account_desc), Create(2, R.string.create_account, R.string.create_account_desc),
Token(3, R.string.input_access_token, R.string.input_access_token_desc), Token(3, R.string.input_access_token, R.string.input_access_token_desc),
} }
@SuppressLint("InflateParams") val views = DlgAccountAddBinding.inflate(activity.layoutInflater)
fun showLoginForm( val dialog = Dialog(activity)
activity: Activity,
instanceArg: String?,
onClickOk: (
dialog: Dialog,
apiHost: Host,
action: Action,
) -> Unit,
) {
val view = activity.layoutInflater.inflate(R.layout.dlg_account_add, null, false)
val etInstance: AutoCompleteTextView = view.findViewById(R.id.etInstance)
val btnOk: View = view.findViewById(R.id.btnOk)
val tvActionDesc: TextView = view.findViewById(R.id.tvActionDesc) private var targetServer: Host? = null
private var targetServerInfo: TootInstance? = null
fun Spinner.getActionDesc(): String = init {
Action.values() for (a in Action.values()) {
.find { it.pos == selectedItemPosition } val subViews =
?.let { activity.getString(it.idDesc) } LvAuthTypeBinding.inflate(activity.layoutInflater, views.llPageAuthType, true)
?: "(null)" subViews.btnAuthType.textResource = a.idName
subViews.tvDesc.textResource = a.idDesc
val spAction = view.findViewById<Spinner>(R.id.spAction).also { sp -> subViews.btnAuthType.setOnClickListener { onAuthTypeSelect(a) }
sp.adapter = ArrayAdapter(
activity,
android.R.layout.simple_spinner_item,
Action.values().map { activity.getString(it.idName) }.toTypedArray()
).apply {
setDropDownViewResource(R.layout.lv_spinner_dropdown)
}
sp.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onNothingSelected(parent: AdapterView<*>?) { // TODO
tvActionDesc.text = sp.getActionDesc()
}
override fun onItemSelected(
parent: AdapterView<*>?,
view: View?,
position: Int,
id: Long,
) {
tvActionDesc.text = sp.getActionDesc()
}
}
} }
views.btnPrev.setOnClickListener { showPage(0) }
tvActionDesc.text = spAction.getActionDesc() views.btnNext.setOnClickListener { nextPage() }
views.btnCancel.setOnClickListener { dialog.cancel() }
if (instanceArg != null && instanceArg.isNotEmpty()) { views.etInstance.setOnEditorActionListener(TextView.OnEditorActionListener { _, actionId, _ ->
etInstance.setText(instanceArg) if (actionId == EditorInfo.IME_ACTION_DONE) {
etInstance.inputType = InputType.TYPE_NULL nextPage()
etInstance.isEnabled = false return@OnEditorActionListener true
etInstance.isFocusable = false
} else {
etInstance.setOnEditorActionListener(TextView.OnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
btnOk.performClick()
return@OnEditorActionListener true
}
false
})
}
val dialog = Dialog(activity)
dialog.setContentView(view)
// 警告がでるが、パラメータ名の指定を削ってはいけない
btnOk.setOnClickListener { _ ->
val instance = etInstance.text.toString().trim { it <= ' ' }
when {
instance.isEmpty() ->
activity.showToast(true, R.string.instance_not_specified)
instance.contains("/") || instance.contains("@") ->
activity.showToast(true, R.string.instance_not_need_slash)
else -> {
val actionPos = spAction.selectedItemPosition
when (val action = Action.values().find { it.pos == actionPos }) {
null -> {
} // will no happened
else -> onClickOk(dialog, Host.parse(instance), action)
}
}
} }
} false
view.findViewById<View>(R.id.btnCancel).setOnClickListener { dialog.cancel() } })
views.etInstance.addTextChangedListener { validateAndShow() }
showPage(0)
initServerNameList()
validateAndShow()
dialog.setContentView(views.root)
dialog.window?.setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.WRAP_CONTENT
)
dialog.show()
}
private fun initServerNameList() {
val instance_list = HashSet<String>().apply { val instance_list = HashSet<String>().apply {
try { try {
activity.resources.openRawResource(R.raw.server_list).use { inStream -> activity.resources.openRawResource(R.raw.server_list).use { inStream ->
@ -146,13 +125,11 @@ object LoginForm {
val adapter = object : ArrayAdapter<String>( val adapter = object : ArrayAdapter<String>(
activity, R.layout.lv_spinner_dropdown, ArrayList() activity, R.layout.lv_spinner_dropdown, ArrayList()
) { ) {
val nameFilter: Filter = object : Filter() { val nameFilter: Filter = object : Filter() {
override fun convertResultToString(value: Any): CharSequence { override fun convertResultToString(value: Any) =
return value as String value as String
}
override fun performFiltering(constraint: CharSequence?): FilterResults = override fun performFiltering(constraint: CharSequence?) =
FilterResults().also { result -> FilterResults().also { result ->
if (constraint?.isNotEmpty() == true) { if (constraint?.isNotEmpty() == true) {
val key = constraint.toString().lowercase() val key = constraint.toString().lowercase()
@ -169,10 +146,7 @@ object LoginForm {
} }
} }
override fun publishResults( override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
constraint: CharSequence?,
results: FilterResults?,
) {
clear() clear()
val values = results?.values val values = results?.values
if (values is StringArray) { if (values is StringArray) {
@ -184,17 +158,109 @@ object LoginForm {
} }
} }
override fun getFilter(): Filter { override fun getFilter(): Filter = nameFilter
return nameFilter
}
} }
adapter.setDropDownViewResource(R.layout.lv_spinner_dropdown) adapter.setDropDownViewResource(R.layout.lv_spinner_dropdown)
etInstance.setAdapter<ArrayAdapter<String>>(adapter) views.etInstance.setAdapter<ArrayAdapter<String>>(adapter)
}
dialog.window?.setLayout( // return validated name. else null
WindowManager.LayoutParams.MATCH_PARENT, private fun validateAndShow(): String? {
WindowManager.LayoutParams.WRAP_CONTENT fun showError(s: String) {
) views.btnNext.isEnabledAlpha = false
dialog.show() views.tvError.visible().text = s
}
val s = views.etInstance.text.toString().trim()
if (s.isEmpty()) {
showError(activity.getString(R.string.instance_not_specified))
return null
}
// コピペミスに合わせたガイド
arrayOf(
"http://",
"https://",
).forEach {
if (s.contains(it)) {
showError(activity.getString(R.string.server_host_name_cant_contains_it, it))
return null
}
}
if (s.contains("/") || s.contains("@")) {
showError(activity.getString(R.string.instance_not_need_slash))
return null
}
//
reBadLetter.findAll(s).joinToString("") { it.value }.notEmpty()?.let {
showError(activity.getString(R.string.server_host_name_cant_contains_it, it))
return null
}
views.tvError.invisible()
views.btnNext.isEnabledAlpha = true
return s
}
private fun showPage(n: Int) {
views.etInstance.dismissDropDown()
views.etInstance.hideKeyboard()
views.llPageServerHost.vg(n == 0)
views.llPageAuthType.vg(n == 1)
val canBack = n != 0
views.btnPrev.vg(canBack)
val canNext = n == 0
views.btnNext.visibleOrInvisible(canNext)
views.tvHeader.textResource = when (n) {
0 -> R.string.server_host_name
else -> R.string.authentication_select
}
}
private fun nextPage() {
activity.run {
launchAndShowError {
val hostname = validateAndShow() ?: return@launchAndShowError
val host = Host.parse(hostname)
var error: String? = null
val tootInstance = try {
runApiTask2(host) {
TootInstance.getOrThrow(it)
}
} catch (ex: Throwable) {
error = ex.message
null
}
if (isDestroyed || isFinishing) return@launchAndShowError
targetServer = host
targetServerInfo = tootInstance
views.tvServerHost.text = tootInstance?.apDomain?.pretty ?: host.pretty
views.tvServerDesc.run {
when (tootInstance) {
null -> {
textColor = attrColor(R.attr.colorRegexFilterError)
text = error
}
else -> {
textColor = attrColor(R.attr.colorTextContent)
text = tootInstance.short_description.notBlank()
?: DecodeOptions(
applicationContext,
LinkHelper.create(tootInstance),
forceHtml = true,
short = true,
).decodeHTML(tootInstance.description)
.replace("""\n[\s\n]+""".toRegex(),"\n")
}
}
}
showPage(1)
}
}
}
private fun onAuthTypeSelect(action: Action) {
targetServer?.let { onClickOk(dialog, it, targetServerInfo, action) }
} }
} }

View File

@ -901,7 +901,7 @@ class SavedAccount(
// ユーザ情報を取得してみる。承認済みなら読めるはず // ユーザ情報を取得してみる。承認済みなら読めるはず
// 読めなければ例外が出る // 読めなければ例外が出る
val userJson = client.getUserCredential( val userJson = client.verifyAccount(
accessToken = accessToken, accessToken = accessToken,
outTokenInfo = null, outTokenInfo = null,
misskeyVersion = 0, // Mastodon only misskeyVersion = 0, // Mastodon only

View File

@ -157,8 +157,19 @@ class CustomEmojiLister(
} }
} }
fun getCachedEmoji(apiHostAscii: String?, shortcode: String): CustomEmoji? = fun getCachedEmoji(apiHostAscii: String?, shortcode: String): CustomEmoji? {
getCached(elapsedTime, apiHostAscii)?.mapShortCode?.get(shortcode) val cache = getCached(elapsedTime, apiHostAscii)
if (cache == null) {
log.w("getCachedEmoji: missing cache for $apiHostAscii")
return null
}
val emoji = cache.mapShortCode.get(shortcode)
if (emoji == null) {
log.w("getCachedEmoji: missing emoji for $shortcode in $apiHostAscii")
return null
}
return emoji
}
private inner class Worker : WorkerBase() { private inner class Worker : WorkerBase() {
@ -303,21 +314,14 @@ class CustomEmojiLister(
if (over <= 0) return if (over <= 0) return
// 古い要素を一時リストに集める // 古い要素を一時リストに集める
val now = elapsedTime
val list = ArrayList<CacheItem>(over)
for (item in cache.values) {
if (now - item.timeUsed > 1000L) list.add(item)
}
// 昇順ソート // 昇順ソート
list.sortBy { it.timeUsed }
// 古い物から順に捨てる // 古い物から順に捨てる
var removed = 0 val now = elapsedTime
for (item in list) { cache.entries
cache.remove(item.key) .filter { now - it.value.timeUsed > 1000L }
if (++removed >= over) break .sortedBy { it.value.timeUsed }
} .take(over)
.forEach { cache.remove(it.key) }
} }
} }
} }

View File

@ -407,6 +407,7 @@ object EmojiDecoder {
) )
when { when {
// 絵文字プロクシを利用できない
apiHostAscii == null -> { apiHostAscii == null -> {
log.w("decodeEmoji Misskey13 missing apiHostAscii") log.w("decodeEmoji Misskey13 missing apiHostAscii")
} }
@ -434,26 +435,20 @@ object EmojiDecoder {
// 通常の絵文字 // 通常の絵文字
when { when {
reHohoemi.matcher(name).find() -> builder.addImageSpan( reHohoemi.matcher(name).find() ->
part, builder.addImageSpan(part, R.drawable.emoji_hohoemi)
R.drawable.emoji_hohoemi reNicoru.matcher(name).find() ->
) builder.addImageSpan(part, R.drawable.emoji_nicoru)
reNicoru.matcher(name).find() -> builder.addImageSpan(
part,
R.drawable.emoji_nicoru
)
else -> { else -> {
// EmojiOneのショートコード // EmojiOneのショートコード
val emoji = if (useEmojioneShortcode) { val emoji = when {
EmojiMap.shortNameMap[name.lowercase().replace('-', '_')] useEmojioneShortcode ->
} else { EmojiMap.shortNameMap[name.lowercase().replace('-', '_')]
null else -> null
} }
when (emoji) {
if (emoji == null) { null -> builder.addUnicodeString(part)
builder.addUnicodeString(part) else -> builder.addImageSpan(part, emoji)
} else {
builder.addImageSpan(part, emoji)
} }
} }
} }
@ -481,19 +476,13 @@ object EmojiDecoder {
} }
override fun onShortCode(prevCodePoint: Int, part: String, name: String) { override fun onShortCode(prevCodePoint: Int, part: String, name: String) {
// カスタム絵文字にマッチするなら変換しない // カスタム絵文字にマッチするなら変換しない
val emojiCustom = emojiMapCustom?.get(name)
if (emojiCustom != null) {
sb.append(part)
return
}
// カスタム絵文字ではなく通常の絵文字のショートコードなら絵文字に変換する // カスタム絵文字ではなく通常の絵文字のショートコードなら絵文字に変換する
val emoji = if (decodeEmojioneShortcode) { val emoji = when {
EmojiMap.shortNameMap[name.lowercase().replace('-', '_')] decodeEmojioneShortcode &&
} else { emojiMapCustom?.get(name) == null ->
null EmojiMap.shortNameMap[name.lowercase().replace('-', '_')]
else -> null
} }
sb.append(emoji?.unifiedCode ?: part) sb.append(emoji?.unifiedCode ?: part)
} }

View File

@ -1,68 +1,146 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout <ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:orientation="vertical" android:fadeScrollbars="false"
> android:scrollbarStyle="outsideOverlay">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
android:text="@string/instance"
/>
<AutoCompleteTextView
android:id="@+id/etInstance"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:hint="@string/instance_hint"
android:imeOptions="actionDone"
android:inputType="textUri"
/>
<Spinner
android:id="@+id/spAction"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="12dp"
android:id="@+id/tvActionDesc"
/>
<LinearLayout <LinearLayout
style="?android:attr/buttonBarStyle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="vertical">
> <!-- header -->
<LinearLayout
<Button android:layout_width="match_parent"
android:id="@+id/btnCancel"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:baselineAligned="false"
android:text="@string/cancel" android:gravity="center_vertical"
/> android:orientation="horizontal">
<Button <TextView
android:id="@+id/btnOk" android:id="@+id/tvHeader"
style="?android:attr/buttonBarButtonStyle" android:layout_width="0dp"
android:layout_width="0dp" android:layout_height="48dp"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:textSize="18sp"
tools:text="@string/server_host_name" />
<ImageButton
android:id="@+id/btnCancel"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@drawable/btn_bg_transparent_round6dp"
android:contentDescription="@string/cancel"
android:src="@drawable/ic_close"
app:tint="?attr/colorColumnHeaderName" />
</LinearLayout>
<!-- page 1: input server name -->
<LinearLayout
android:id="@+id/llPageServerHost"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_margin="12dp"
android:text="@string/ok" android:orientation="vertical">
/>
<AutoCompleteTextView
android:id="@+id/etInstance"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/instance_hint"
android:imeOptions="actionDone"
android:includeFontPadding="false"
android:inputType="textUri" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:baselineAligned="false"
android:gravity="top"
android:orientation="horizontal">
<TextView
android:id="@+id/tvError"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center|start"
android:textColor="?attr/colorRegexFilterError"
tools:text="error error" />
<Button
android:id="@+id/btnNext"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/next_step" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autoLink="web"
android:gravity="top"
android:text="@string/input_server_name_desc"
android:textColor="?attr/colorTextHelp" />
</LinearLayout>
<!-- page 2: select action -->
<LinearLayout
android:id="@+id/llPageAuthType"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="12dp"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:baselineAligned="false"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tvServerHost"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_weight="1"
android:includeFontPadding="false"
android:textStyle="bold"
app:autoSizeTextType="uniform"
tools:text="mastdon.social" />
<ImageButton
android:id="@+id/btnPrev"
android:layout_width="48dp"
android:layout_height="48dp"
android:contentDescription="@string/previous"
android:src="@drawable/ic_edit"
app:tint="?attr/colorColumnHeaderName" />
</LinearLayout>
<TextView
android:id="@+id/tvServerDesc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:background="?attr/colorColumnSettingBackground"
android:padding="2dp"
android:textColor="?attr/colorTextContent"
tools:text="error error" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:autoLink="web"
android:text="@string/authentication_select_desc"
android:textColor="?attr/colorTextHelp" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</LinearLayout> </ScrollView>

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingVertical="12dp">
<Button
android:id="@+id/btnAuthType"
android:layout_width="match_parent"
android:textAllCaps="false"
android:layout_height="wrap_content"
tools:text="@string/input_access_token" />
<TextView
android:id="@+id/tvDesc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:autoLink="web"
android:textColor="?attr/colorTextHelp"
tools:text="@string/input_access_token_desc" />
</LinearLayout>

View File

@ -1181,4 +1181,13 @@
<string name="color_theme_changed">色テーマのデフォルトが変わりました。カスタマイズ済の色設定と競合する場合があります。 アプリ設定の色セクションを確認することをお勧めします。</string> <string name="color_theme_changed">色テーマのデフォルトが変わりました。カスタマイズ済の色設定と競合する場合があります。 アプリ設定の色セクションを確認することをお勧めします。</string>
<string name="tablet_snap">スクロール時にカラム端と画面端を揃える (アプリ再起動が必要)</string> <string name="tablet_snap">スクロール時にカラム端と画面端を揃える (アプリ再起動が必要)</string>
<string name="server_host_name">サーバのホスト名</string>
<string name="input_server_name_desc">\n\n・サーバのURLのホスト名部分。\n・https:// や/以降のパスを含まない。\n・サーバ一覧 https://joinmastodon.org/servers</string>
<string name="authentication_select">認証方法の選択</string>
<string name="authentication_select_desc">サーバにアクセスする方法を以下から選択してください。</string>
<string name="server_host_name_cant_contains_it">ホスト名に使えない文字が含まれています。[%1$s]</string>
<string name="next_step">次へ</string>
<string name="user_creation_not_supported">[%1$s, %2$s] サーバ種別はアプリからのユーザ登録に対応していません。</string>
</resources> </resources>

View File

@ -23,7 +23,7 @@
<color name="Light_colorPostFormBackground">#eee</color> <color name="Light_colorPostFormBackground">#eee</color>
<color name="Light_colorActionBarBg">#ccc</color> <color name="Light_colorActionBarBg">#ccc</color>
<color name="Light_colorActionBarBgStacked">#ddd</color> <color name="Light_colorActionBarBgStacked">#ddd</color>
<color name="Light_colorStatusBarBg">#303030</color> <!-- ステータスバー背景 --> <color name="Light_colorStatusBarBg">#707070</color> <!-- ステータスバー背景 -->
<color name="Light_colorProfileBackgroundMask">#C0FFFFFF</color> <color name="Light_colorProfileBackgroundMask">#C0FFFFFF</color>
<color name="Light_colorRefreshErrorBg">#D222</color> <color name="Light_colorRefreshErrorBg">#D222</color>
<color name="Light_colorRegexFilterError">#f00</color> <color name="Light_colorRegexFilterError">#f00</color>
@ -41,6 +41,8 @@
<color name="Light_colorTextContent">#ff333333</color> <color name="Light_colorTextContent">#ff333333</color>
<color name="Light_colorTextDivider">#80000000</color> <color name="Light_colorTextDivider">#80000000</color>
<color name="Light_colorTextHelp">#5a5a5a</color> <color name="Light_colorTextHelp">#5a5a5a</color>
<color name="Light_colorTextHint">#40000000</color>
<color name="Light_colorTextTimeSmall">#ff666666</color> <color name="Light_colorTextTimeSmall">#ff666666</color>
<color name="Light_colorThumbnailBackground">#20000000</color> <color name="Light_colorThumbnailBackground">#20000000</color>
<color name="Light_colotListItemDrag">#AACCCCCC</color> <color name="Light_colotListItemDrag">#AACCCCCC</color>
@ -88,6 +90,8 @@
<color name="Dark_colorTextColumnListItem">#66FFFFFF</color> <color name="Dark_colorTextColumnListItem">#66FFFFFF</color>
<color name="Dark_colorTextContent">#dddddd</color> <color name="Dark_colorTextContent">#dddddd</color>
<color name="Dark_colorTextHelp">#ccFFFFFF</color> <color name="Dark_colorTextHelp">#ccFFFFFF</color>
<color name="Dark_colorTextHint">#40ffffff</color>
<color name="Dark_colorTextTimeSmall">#BBBBBB</color> <color name="Dark_colorTextTimeSmall">#BBBBBB</color>
<color name="Dark_colorThumbnailBackground">#20ffffff</color> <color name="Dark_colorThumbnailBackground">#20ffffff</color>
<color name="Dark_colorAppCompatAccent">#0080ff</color> <color name="Dark_colorAppCompatAccent">#0080ff</color>
@ -134,7 +138,9 @@
<color name="Mastodon_colorTextColumnHeaderPageNumber">#e4e4e4</color> <color name="Mastodon_colorTextColumnHeaderPageNumber">#e4e4e4</color>
<color name="Mastodon_colorTextColumnListItem">#66FFFFFF</color> <color name="Mastodon_colorTextColumnListItem">#66FFFFFF</color>
<color name="Mastodon_colorTextContent">#dddddd</color> <color name="Mastodon_colorTextContent">#dddddd</color>
<color name="Mastodon_colorTextHelp">#ccFFFFFF</color> <color name="Mastodon_colorTextHelp">#bbFFFFFF</color>
<color name="Mastodon_colorTextHint">#40FFFFFF</color>
<color name="Mastodon_colorTextTimeSmall">#BBBBBB</color> <color name="Mastodon_colorTextTimeSmall">#BBBBBB</color>
<color name="Mastodon_colorThumbnailBackground">#20ffffff</color> <color name="Mastodon_colorThumbnailBackground">#20ffffff</color>
<color name="Mastodon_colorAppCompatAccent">#0080ff</color> <color name="Mastodon_colorAppCompatAccent">#0080ff</color>
@ -146,4 +152,8 @@
<!-- 通知のアクセント色 --> <!-- 通知のアクセント色 -->
<color name="colorOsNotificationAccent">#B3E1FF</color> <color name="colorOsNotificationAccent">#B3E1FF</color>
<!-- 白テーマのナビゲーションバーは白ではない -->
<color name="colorNavigationBarWorkaround">#707070</color>
</resources> </resources>

View File

@ -1186,5 +1186,13 @@
<string name="acct_customize">Acct customize</string> <string name="acct_customize">Acct customize</string>
<string name="delete_confirm">delete \"%1$s\" ?</string> <string name="delete_confirm">delete \"%1$s\" ?</string>
<string name="color_theme_changed">Default color theme has been updated. This may conflict with your customized color settings. We recommend reviewing the colors section in app settings.</string> <string name="color_theme_changed">Default color theme has been updated. This may conflict with your customized color settings. We recommend reviewing the colors section in app settings.</string>
<string name="tablet_snap" >Align column edge to screen edge when scrolling (app restart required)</string> <string name="tablet_snap">Align column edge to screen edge when scrolling (app restart required)</string>
<string name="server_host_name">Server\'s host name</string>
<string name="input_server_name_desc">・The host name part in the server\'s URL.\n・without http:// or trailing /path… .\n・Mastodon server list: https://joinmastodon.org/servers</string>
<string name="authentication_select">Select authentication method</string>
<string name="authentication_select_desc">Select how to access the server from below.</string>
<string name="server_host_name_cant_contains_it">host name can\'t contains [%1$s].</string>
<string name="next_step">Next step</string>
<string name="user_creation_not_supported">[%1$s, %2$s] server type does not support user registration from app.</string>
</resources> </resources>

View File

@ -10,6 +10,7 @@
<item name="android:textColorPrimary">@color/Light_colorTextContent</item> <item name="android:textColorPrimary">@color/Light_colorTextContent</item>
<item name="android:textColorSecondary">@color/Light_colorTextContent</item> <item name="android:textColorSecondary">@color/Light_colorTextContent</item>
<item name="android:textColorTertiary">@color/Light_colorTextContent</item> <item name="android:textColorTertiary">@color/Light_colorTextContent</item>
<item name="android:textColorHint">@color/Light_colorTextHint</item>
<!-- TabLayoutの下線やRadioButton選択時などの色 --> <!-- TabLayoutの下線やRadioButton選択時などの色 -->
<item name="colorAccent">@color/Light_colorAppCompatAccent</item> <item name="colorAccent">@color/Light_colorAppCompatAccent</item>
@ -23,7 +24,12 @@
<item name="colorPrimaryDark">@color/Light_colorStatusBarBg</item> <item name="colorPrimaryDark">@color/Light_colorStatusBarBg</item>
<!-- ナビゲーションバー(戻るキー・ホームキーなどがあるバー)の背景色 --> <!-- ナビゲーションバー(戻るキー・ホームキーなどがあるバー)の背景色 -->
<item name="android:navigationBarColor">@color/Light_colorColumnStripBackground</item> <!--
ナビゲーションバーのデフォルト色は通常はカラムストリップと揃えたいが、
Lightテーマでそれをやるとボタン図柄の色が白いままの端末で問題になる。
仕方ないので白テーマだけデフォルト色が異なる。
-->
<item name="android:navigationBarColor">@color/colorNavigationBarWorkaround</item>
<!-- ウィンドウ背景Drawable --> <!-- ウィンドウ背景Drawable -->
<item name="android:windowBackground">@drawable/window_background</item> <item name="android:windowBackground">@drawable/window_background</item>
@ -104,6 +110,7 @@
<item name="android:textColorPrimary">@color/Dark_colorTextContent</item> <item name="android:textColorPrimary">@color/Dark_colorTextContent</item>
<item name="android:textColorSecondary">@color/Dark_colorTextContent</item> <item name="android:textColorSecondary">@color/Dark_colorTextContent</item>
<item name="android:textColorTertiary">@color/Dark_colorTextContent</item> <item name="android:textColorTertiary">@color/Dark_colorTextContent</item>
<item name="android:textColorHint">@color/Dark_colorTextHint</item>
<!-- TabLayoutの下線やRadioButton選択時などの色 --> <!-- TabLayoutの下線やRadioButton選択時などの色 -->
<item name="colorAccent">@color/Dark_colorAppCompatAccent</item> <item name="colorAccent">@color/Dark_colorAppCompatAccent</item>
@ -205,6 +212,7 @@
<item name="android:textColorPrimary">@color/Mastodon_colorTextContent</item> <item name="android:textColorPrimary">@color/Mastodon_colorTextContent</item>
<item name="android:textColorSecondary">@color/Mastodon_colorTextContent</item> <item name="android:textColorSecondary">@color/Mastodon_colorTextContent</item>
<item name="android:textColorTertiary">@color/Mastodon_colorTextContent</item> <item name="android:textColorTertiary">@color/Mastodon_colorTextContent</item>
<item name="android:textColorHint">@color/Mastodon_colorTextHint</item>
<!-- TabLayoutの下線やRadioButton選択時などの色 --> <!-- TabLayoutの下線やRadioButton選択時などの色 -->
<item name="colorAccent">@color/Mastodon_colorAppCompatAccent</item> <item name="colorAccent">@color/Mastodon_colorAppCompatAccent</item>

View File

@ -52,7 +52,7 @@ class DispatchersTest {
// プロパティの定義順序に注意 // プロパティの定義順序に注意
@get:Rule @get:Rule
val dispatcheRule = AppTestDispatcherRule() val dispatcheRule = TestDispatcherRule()
// リポジトリのスケジューラを共有する // リポジトリのスケジューラを共有する
private val repository = Repository(dispatcheRule.testDispatcher) private val repository = Repository(dispatcheRule.testDispatcher)

View File

@ -18,7 +18,7 @@ import org.junit.runner.Description
* https://stackoverflow.com/questions/69423060/viewmodel-ui-testing-with-junit-5 * https://stackoverflow.com/questions/69423060/viewmodel-ui-testing-with-junit-5
*/ */
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class AppTestDispatcherRule( class TestDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() { ) : TestWatcher() {