1
0
mirror of https://github.com/tateisu/SubwayTooter synced 2025-02-01 11:26:48 +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.TootInstance
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.util.SimpleHttpClientImpl
import jp.juggler.util.log.LogCategory
@ -30,7 +30,7 @@ class TestTootInstance {
// テスト毎に書くと複数テストで衝突するので、MainDispatcherRuleに任せる
// プロパティは記述順に初期化されることに注意
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
val mainDispatcherRule = TestDispatcherRule()
private val client by lazy {
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.table.ClientInfo
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.util.SimpleHttpClient
import jp.juggler.util.data.*
@ -36,7 +36,7 @@ class TestTootApiClient {
// テスト毎に書くと複数テストで衝突するので、MainDispatcherRuleに任せる
// プロパティは記述順に初期化されることに注意
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
val mainDispatcherRule = TestDispatcherRule()
companion object {
private val log = LogCategory("TestTootApiClient")
@ -1069,7 +1069,7 @@ class TestTootApiClient {
val (ti, ri) = TootInstance.get(client)
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)
println("authUri=$authUri")
@ -1189,8 +1189,7 @@ class TestTootApiClient {
}
@Test
fun testGetInstanceInformation() =
runTest {
fun testGetInstanceInformation() = runTest {
val callback = ProgressRecordTootApiCallback()
val client = TootApiClient(
appContext,

View File

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

View File

@ -21,7 +21,7 @@ import androidx.core.content.ContextCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.recyclerview.widget.RecyclerView
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.actmain.*
import jp.juggler.subwaytooter.actpost.CompletionHelper
@ -289,7 +289,8 @@ class ActMain : AppCompatActivity(),
ActAccountSetting.RESULT_INPUT_ACCESS_TOKEN ->
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 jp.juggler.subwaytooter.api.TootParser
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.UserRelation
import jp.juggler.subwaytooter.util.matchHost
@ -24,14 +25,12 @@ internal suspend fun AppCompatActivity.addPseudoAccount(
try {
suspend fun AppCompatActivity.getInstanceInfo(): TootInstance? {
var resultTi: TootInstance? = null
val result = runApiTask(host) { client ->
val (instance, instanceResult) = TootInstance.get(client)
resultTi = instance
instanceResult
return try {
runApiTask2(host) { TootInstance.getOrThrow(it) }
} catch (ex: Throwable) {
showApiError(ex)
null
}
result?.error?.let { showToast(true, it) }
return resultTi
}
val acct = Acct.parse("?", host)

View File

@ -7,23 +7,24 @@ import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.*
import jp.juggler.subwaytooter.actmain.addColumn
import jp.juggler.subwaytooter.actmain.afterAccountVerify
import jp.juggler.subwaytooter.actmain.defaultInsertPosition
import jp.juggler.subwaytooter.api.*
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.column.ColumnType
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.pref.*
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
import jp.juggler.util.data.buildJsonObject
import jp.juggler.util.data.encodePercent
import jp.juggler.util.log.LogCategory
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() {
val activity = this
LoginForm.showLoginForm(this, null) { dialogHost, instance, action ->
showLoginForm { dialogHost, apiHost, serverInfo, action ->
launchMain {
try {
when (action) {
// ログイン画面を開く
LoginForm.Action.Existing ->
runApiTask2(instance) { client ->
val authUri = client.authStep1()
withContext(AppDispatchers.MainImmediate) {
openBrowser(authUri)
dialogHost.dismissSafe()
}
LoginForm.Action.Login -> {
val authUri = runApiTask2(apiHost) { it.authStep1() }
openBrowser(authUri)
dialogHost.dismissSafe()
}
LoginForm.Action.Pseudo -> {
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 ->
runApiTask2(instance) { client ->
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()
}
}
}
createUser(apiHost, serverInfo) { dialogHost.dismissSafe() }
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,
)
}
}
)
}
}
accessTokenPrompt(apiHost) { dialogHost.dismissSafe() }
}
} catch (ex: Throwable) {
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) {
// if account is default account of tablet mode,
// 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.entity.TootAccount
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.util.coroutine.launchMain
import jp.juggler.util.data.wrapWeakReference
import jp.juggler.util.log.LogCategory
import jp.juggler.util.ui.attrColor
private val log = LogCategory("ActPostCharCount")
// 最大文字数を取得する
// 暫定で仮の値を返すことがある
// 裏で取得し終わったら updateTextCount() を呼び出す
@ -25,17 +28,17 @@ private fun ActPost.getMaxCharCount(): Int {
// 同時に実行するタスクは1つまで
if (jobMaxCharCount?.get()?.isActive != true) {
jobMaxCharCount = launchMain {
var newInfo: TootInstance? = null
runApiTask(account, progressStyle = ApiTask.PROGRESS_NONE) { client ->
val (ti, result) = TootInstance.get(client)
newInfo = ti
result
try {
runApiTask2(account, progressStyle = ApiTask.PROGRESS_NONE) {
TootInstance.getOrThrow(it)
}
if (isFinishing || isDestroyed) return@launchMain
updateTextCount()
} catch (ex: Throwable) {
log.w(ex, "getMaxCharCount failed.")
}
if (isFinishing || isDestroyed) return@launchMain
if (newInfo != null) updateTextCount()
}.wrapWeakReference
}
// fall thru
}

View File

@ -430,35 +430,21 @@ class TootApiClient(
forceUpdateClient: Boolean = false,
): Uri {
val (ti, ri) = TootInstance.get(this)
// 情報が取れなくても続ける
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}")
else -> auth.authStep1(ti, forceUpdateClient)
}
}
suspend fun getUserCredential(
suspend fun verifyAccount(
accessToken: String,
outTokenInfo: JsonObject?,
misskeyVersion: Int = 0,
) = AuthBase.findAuthForUserCredentian(this, misskeyVersion)
) = AuthBase.findAuthForVerifyAccount(this, 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データ以外を扱うリクエスト

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

View File

@ -38,44 +38,34 @@ abstract class AuthBase {
).firstNotNullOfOrNull { it.notBlank() }
?: DEFAULT_CLIENT_NAME
fun findAuth(client: TootApiClient, ti: TootInstance?, ri: TootApiResult?): AuthBase? =
fun findAuthForVerifyAccount(client: TootApiClient, misskeyVersionMajor: Int) =
when {
// インスタンス情報を取得できない
ti == null -> when (ri?.response?.code) {
misskeyVersionMajor >= 13 -> MisskeyAuth13(client)
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
// 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 =
fun findAuthForAuthCallback(client: TootApiClient, callbackUrl: String) =
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)
fun findAuthForCreateUser(client: TootApiClient, ti: TootInstance?) =
when (ti?.instanceType) {
InstanceType.Mastodon -> MastodonAuth(client)
else -> null
}
}
@ -85,7 +75,6 @@ abstract class AuthBase {
protected val account get() = client.account
protected val context get() = client.context
/**
* クライアントを登録してブラウザで開く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.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.InstanceType
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.util.LinkHelper
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.buildJsonObject
import jp.juggler.util.data.notEmpty
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.errorEx
@ -233,11 +235,10 @@ class MastodonAuth(override val client: TootApiClient) : AuthBase() {
// ?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) }
arrayOf("error_description", "error")
.mapNotNull { uri.getQueryParameter(it)?.trim()?.notEmpty() }
.notEmpty()
?.let { error(it.joinToString("\n")) }
// subwaytooter://oauth(\d*)/
// ?code=113cc036e078ac500d3d0d3ad345cd8181456ab087abc67270d40f40a4e9e3c2
@ -296,16 +297,14 @@ class MastodonAuth(override val client: TootApiClient) : AuthBase() {
misskeyVersion = 0
)
val (ti, ri) = TootInstance.getEx(client, forceAccessToken = accessToken)
ti ?: error("can't get server information. ${ri?.error}")
val ti = TootInstance.getExOrThrow(client, forceAccessToken = accessToken)
val parser = TootParser(context, linkHelper = LinkHelper.create(ti))
return Auth2Result(
tootInstance = ti,
accountJson = accountJson,
tokenJson = tokenInfo,
tootAccount = TootParser(context, linkHelper = LinkHelper.create(ti))
.account(accountJson)
?: error("can't parse user information."),
accountJson = accountJson,
tootAccount = parser.account(accountJson)
?: error("can't parse user information.")
)
}
@ -322,26 +321,48 @@ class MastodonAuth(override val client: TootApiClient) : AuthBase() {
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
)
params: CreateUserParams,
): Auth2Result {
val apiHost = apiHost ?: error("createUser: missing apiHost")
val tokenJson = api.createUser(
apiHost = apiHost,
clientCredential = clientInfo.string(KEY_CLIENT_CREDENTIAL)
?: error("createUser: missing client credential"),
params = params,
)
val accessToken = tokenJson.string("access_token")
?: error("can't get user access token")
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(
apiHost: Host,
clientCredential: String,
username: String,
email: String,
password: String,
agreement: Boolean,
reason: String?,
params: CreateUserParams,
) = buildJsonObject {
put("username", username)
put("email", email)
put("password", password)
put("agreement", agreement)
reason?.notEmpty()?.let { put("reason", reason) }
put("username", params.username)
put("email", params.email)
put("password", params.password)
put("agreement", params.agreement)
params.reason?.notEmpty()?.let { put("reason", it) }
}.encodeQuery().toFormRequestBody().toPost()
.url("https://${apiHost.ascii}/api/v1/accounts")
.header("Authorization", "Bearer $clientCredential")

View File

@ -201,8 +201,7 @@ class MisskeyAuth10(override val client: TootApiClient) : AuthBase() {
} ?: error("missing account db_id=$dbId")
}
val (ti, r2) = TootInstance.get(client)
ti ?: error("${r2?.error} ($apiHost)")
val ti = TootInstance.getOrThrow(client)
val parser = TootParser(
context,
@ -230,14 +229,10 @@ class MisskeyAuth10(override val client: TootApiClient) : AuthBase() {
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
@ -245,7 +240,9 @@ class MisskeyAuth10(override val client: TootApiClient) : AuthBase() {
val apiKey = "$accessToken$appSecret".encodeUTF8().digestSHA256().encodeHexLower()
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.")
}
val (ti, r2) = TootInstance.get(client)
ti ?: error("missing server information. ${r2?.error}")
val ti = TootInstance.getOrThrow(client)
val misskeyVersion = ti.misskeyVersionMajor
@ -136,13 +135,13 @@ class MisskeyAuth13(override val client: TootApiClient) : AuthBase() {
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)
},
accountJson = accountJson,
tootAccount = user,
)
}

View File

@ -480,7 +480,32 @@ class TootInstance(parser: TootParser, src: JsonObject) {
fun getCached(apiHost: Host) = 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(
client: TootApiClient,

View File

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

View File

@ -1,25 +1,56 @@
package jp.juggler.subwaytooter.dialog
import android.annotation.SuppressLint
import android.app.Activity
import android.app.Dialog
import android.text.InputType
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.widget.*
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.widget.addTextChangedListener
import jp.juggler.subwaytooter.R
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.ui.*
import org.jetbrains.anko.textColor
import org.jetbrains.anko.textResource
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.IDN
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>()
@ -28,103 +59,51 @@ object LoginForm {
@StringRes val idName: Int,
@StringRes val idDesc: Int,
) {
Existing(0, R.string.existing_account, R.string.existing_account_desc),
Login(0, R.string.existing_account, R.string.existing_account_desc),
Pseudo(1, R.string.pseudo_account, R.string.pseudo_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),
}
@SuppressLint("InflateParams")
fun showLoginForm(
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 views = DlgAccountAddBinding.inflate(activity.layoutInflater)
val dialog = Dialog(activity)
val tvActionDesc: TextView = view.findViewById(R.id.tvActionDesc)
private var targetServer: Host? = null
private var targetServerInfo: TootInstance? = null
fun Spinner.getActionDesc(): String =
Action.values()
.find { it.pos == selectedItemPosition }
?.let { activity.getString(it.idDesc) }
?: "(null)"
val spAction = view.findViewById<Spinner>(R.id.spAction).also { sp ->
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()
}
}
init {
for (a in Action.values()) {
val subViews =
LvAuthTypeBinding.inflate(activity.layoutInflater, views.llPageAuthType, true)
subViews.btnAuthType.textResource = a.idName
subViews.tvDesc.textResource = a.idDesc
subViews.btnAuthType.setOnClickListener { onAuthTypeSelect(a) }
}
tvActionDesc.text = spAction.getActionDesc()
if (instanceArg != null && instanceArg.isNotEmpty()) {
etInstance.setText(instanceArg)
etInstance.inputType = InputType.TYPE_NULL
etInstance.isEnabled = false
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)
}
}
views.btnPrev.setOnClickListener { showPage(0) }
views.btnNext.setOnClickListener { nextPage() }
views.btnCancel.setOnClickListener { dialog.cancel() }
views.etInstance.setOnEditorActionListener(TextView.OnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
nextPage()
return@OnEditorActionListener true
}
}
view.findViewById<View>(R.id.btnCancel).setOnClickListener { dialog.cancel() }
false
})
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 {
try {
activity.resources.openRawResource(R.raw.server_list).use { inStream ->
@ -146,13 +125,11 @@ object LoginForm {
val adapter = object : ArrayAdapter<String>(
activity, R.layout.lv_spinner_dropdown, ArrayList()
) {
val nameFilter: Filter = object : Filter() {
override fun convertResultToString(value: Any): CharSequence {
return value as String
}
override fun convertResultToString(value: Any) =
value as String
override fun performFiltering(constraint: CharSequence?): FilterResults =
override fun performFiltering(constraint: CharSequence?) =
FilterResults().also { result ->
if (constraint?.isNotEmpty() == true) {
val key = constraint.toString().lowercase()
@ -169,10 +146,7 @@ object LoginForm {
}
}
override fun publishResults(
constraint: CharSequence?,
results: FilterResults?,
) {
override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
clear()
val values = results?.values
if (values is StringArray) {
@ -184,17 +158,109 @@ object LoginForm {
}
}
override fun getFilter(): Filter {
return nameFilter
}
override fun getFilter(): Filter = nameFilter
}
adapter.setDropDownViewResource(R.layout.lv_spinner_dropdown)
etInstance.setAdapter<ArrayAdapter<String>>(adapter)
views.etInstance.setAdapter<ArrayAdapter<String>>(adapter)
}
dialog.window?.setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.WRAP_CONTENT
)
dialog.show()
// return validated name. else null
private fun validateAndShow(): String? {
fun showError(s: String) {
views.btnNext.isEnabledAlpha = false
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,
outTokenInfo = null,
misskeyVersion = 0, // Mastodon only

View File

@ -157,8 +157,19 @@ class CustomEmojiLister(
}
}
fun getCachedEmoji(apiHostAscii: String?, shortcode: String): CustomEmoji? =
getCached(elapsedTime, apiHostAscii)?.mapShortCode?.get(shortcode)
fun getCachedEmoji(apiHostAscii: String?, shortcode: String): CustomEmoji? {
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() {
@ -303,21 +314,14 @@ class CustomEmojiLister(
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
for (item in list) {
cache.remove(item.key)
if (++removed >= over) break
}
val now = elapsedTime
cache.entries
.filter { now - it.value.timeUsed > 1000L }
.sortedBy { it.value.timeUsed }
.take(over)
.forEach { cache.remove(it.key) }
}
}
}

View File

@ -407,6 +407,7 @@ object EmojiDecoder {
)
when {
// 絵文字プロクシを利用できない
apiHostAscii == null -> {
log.w("decodeEmoji Misskey13 missing apiHostAscii")
}
@ -434,26 +435,20 @@ object EmojiDecoder {
// 通常の絵文字
when {
reHohoemi.matcher(name).find() -> builder.addImageSpan(
part,
R.drawable.emoji_hohoemi
)
reNicoru.matcher(name).find() -> builder.addImageSpan(
part,
R.drawable.emoji_nicoru
)
reHohoemi.matcher(name).find() ->
builder.addImageSpan(part, R.drawable.emoji_hohoemi)
reNicoru.matcher(name).find() ->
builder.addImageSpan(part, R.drawable.emoji_nicoru)
else -> {
// EmojiOneのショートコード
val emoji = if (useEmojioneShortcode) {
EmojiMap.shortNameMap[name.lowercase().replace('-', '_')]
} else {
null
val emoji = when {
useEmojioneShortcode ->
EmojiMap.shortNameMap[name.lowercase().replace('-', '_')]
else -> null
}
if (emoji == null) {
builder.addUnicodeString(part)
} else {
builder.addImageSpan(part, emoji)
when (emoji) {
null -> builder.addUnicodeString(part)
else -> builder.addImageSpan(part, emoji)
}
}
}
@ -481,19 +476,13 @@ object EmojiDecoder {
}
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) {
EmojiMap.shortNameMap[name.lowercase().replace('-', '_')]
} else {
null
val emoji = when {
decodeEmojioneShortcode &&
emojiMapCustom?.get(name) == null ->
EmojiMap.shortNameMap[name.lowercase().replace('-', '_')]
else -> null
}
sb.append(emoji?.unifiedCode ?: part)
}

View File

@ -1,68 +1,146 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<ScrollView 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_height="wrap_content"
android:orientation="vertical"
>
android:layout_height="match_parent"
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
style="?android:attr/buttonBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
>
<Button
android:id="@+id/btnCancel"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="0dp"
android:orientation="vertical">
<!-- header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/cancel"
/>
android:baselineAligned="false"
android:gravity="center_vertical"
android:orientation="horizontal">
<Button
android:id="@+id/btnOk"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="0dp"
<TextView
android:id="@+id/tvHeader"
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_weight="1"
android:text="@string/ok"
/>
android:layout_margin="12dp"
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>
</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="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>

View File

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

View File

@ -1186,5 +1186,13 @@
<string name="acct_customize">Acct customize</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="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>

View File

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

View File

@ -52,7 +52,7 @@ class DispatchersTest {
// プロパティの定義順序に注意
@get:Rule
val dispatcheRule = AppTestDispatcherRule()
val dispatcheRule = TestDispatcherRule()
// リポジトリのスケジューラを共有する
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
*/
@ExperimentalCoroutinesApi
class AppTestDispatcherRule(
class TestDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {