fix accesstoken update issue

This commit is contained in:
tateisu 2023-02-09 09:05:59 +09:00
parent 7e7f726e6a
commit 03352bb180
15 changed files with 396 additions and 353 deletions

View File

@ -32,6 +32,7 @@ import jp.juggler.util.data.notEmpty
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
import jp.juggler.util.ui.dismissSafe
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.concurrent.TimeUnit
@ -256,12 +257,16 @@ fun ActMain.launchDialogs() {
}
}
fun ActMain.afterNotificationGranted() {
suspend fun ActMain.afterNotificationGranted() {
sideMenuAdapter.filterListItems()
// Workの掃除
WorkManager.getInstance(applicationContext).pruneWork()
// 定期的にendpointを再登録したい
PushWorker.enqueueRegisterEndpoint(applicationContext, keepAliveMode = true)
// 認証やアクセストークン更新から戻ってきた時に処理を重ねたくない
delay(2000L)
if (!accountVerifyMutex.isLocked) {
// 定期的にendpointを再登録したい
PushWorker.enqueueRegisterEndpoint(applicationContext, keepAliveMode = true)
}
}

View File

@ -31,7 +31,6 @@ import jp.juggler.subwaytooter.notification.checkNotificationImmediateAll
import jp.juggler.subwaytooter.notification.recycleClickedNotification
import jp.juggler.subwaytooter.pref.PrefDevice
import jp.juggler.subwaytooter.pref.prefDevice
import jp.juggler.subwaytooter.push.PushWorker
import jp.juggler.subwaytooter.push.fcmHandler
import jp.juggler.subwaytooter.push.pushRepo
import jp.juggler.subwaytooter.table.SavedAccount
@ -44,7 +43,8 @@ import jp.juggler.util.data.groupEx
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
import jp.juggler.util.queryIntentActivitiesCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.unifiedpush.android.connector.UnifiedPush
@ -229,32 +229,36 @@ private fun ActMain.handleOAuth2Callback(uri: Uri) {
}
}
val accountVerifyMutex = Mutex()
/**
* アカウントを確認した後に呼ばれる
* @return 何かデータを更新したら真
*/
fun ActMain.afterAccountVerify(auth2Result: Auth2Result): Boolean = auth2Result.run {
suspend fun ActMain.afterAccountVerify(auth2Result: Auth2Result): Boolean = auth2Result.run {
accountVerifyMutex.withLock {
// ユーザ情報中のacctはfull acct ではないので、組み立てる
val newAcct = Acct.parse(tootAccount.username, apDomain)
// ユーザ情報中のacctはfull acct ではないので、組み立てる
val newAcct = Acct.parse(tootAccount.username, apDomain)
// full acctだよな
"""\A[^@]+@[^@]+\z""".toRegex().find(newAcct.ascii)
?: error("afterAccountAdd: incorrect userAcct. ${newAcct.ascii}")
// full acctだよな
"""\A[^@]+@[^@]+\z""".toRegex().find(newAcct.ascii)
?: error("afterAccountAdd: incorrect userAcct. ${newAcct.ascii}")
// 「アカウント追加のハズが既存アカウントで認証していた」
// 「アクセストークン更新のハズが別アカウントで認証していた」
// などを防止するため、full acctでアプリ内DBを検索
when (val sa = daoSavedAccount.loadAccountByAcct(newAcct)) {
null -> afterAccountAdd(newAcct, auth2Result)
else -> afterAccessTokenUpdate(auth2Result, sa)
// 「アカウント追加のハズが既存アカウントで認証していた」
// 「アクセストークン更新のハズが別アカウントで認証していた」
// などを防止するため、full acctでアプリ内DBを検索
when (val sa = daoSavedAccount.loadAccountByAcct(newAcct)) {
null -> afterAccountAdd(newAcct, auth2Result)
else -> afterAccessTokenUpdate(auth2Result, sa)
}
}
}
private fun ActMain.afterAccessTokenUpdate(
private suspend fun ActMain.afterAccessTokenUpdate(
auth2Result: Auth2Result,
sa: SavedAccount,
): Boolean {
log.i("afterAccessTokenUpdate token ${sa.bearerAccessToken ?: sa.misskeyApiToken} =>${auth2Result.tokenJson}")
// DBの情報を更新する
authRepo.updateTokenInfo(sa, auth2Result)
@ -275,7 +279,7 @@ private fun ActMain.afterAccessTokenUpdate(
return true
}
private fun ActMain.afterAccountAdd(
private suspend fun ActMain.afterAccountAdd(
newAcct: Acct,
auth2Result: Auth2Result,
): Boolean {
@ -350,17 +354,22 @@ fun ActMain.handleSharedIntent(intent: Intent) {
}
// アカウントを追加/更新したらappServerHashの取得をやりなおす
fun ActMain.updatePushDistributer() {
suspend fun ActMain.updatePushDistributer() {
when {
fcmHandler.noFcm && prefDevice.pushDistributor.isNullOrEmpty() -> {
try {
selectPushDistributor()
// 選択したら
} catch (_: CancellationException) {
// 選択しなかった場合は購読の更新を行わない
selectPushDistributor()
// 選択しなかった場合は購読の更新を行わない
}
else -> {
runInProgress(cancellable = false) { reporter ->
withContext(AppDispatchers.DEFAULT) {
pushRepo.switchDistributor(
prefDevice.pushDistributor,
reporter = reporter
)
}
}
}
else -> PushWorker.enqueueRegisterEndpoint(this)
}
}

View File

@ -23,6 +23,9 @@ class AuthMastodon(override val client: TootApiClient) : AuthBase() {
companion object {
private val log = LogCategory("MastodonAuth")
@Suppress("MayBeConstant")
val DEBUG_AUTH = false
const val callbackUrl = "${BuildConfig.customScheme}://oauth/"
fun mastodonScope(ti: TootInstance?) = when {
@ -153,6 +156,7 @@ class AuthMastodon(override val client: TootApiClient) : AuthBase() {
put(KEY_AUTH_VERSION, AUTH_VERSION)
put(KEY_CLIENT_SCOPE, scopeString)
}
if(DEBUG_AUTH) log.i("DEBUG_AUTH client_id=${clientInfo.string("client_id")}")
// client credentialを取得して保存する
// この時点ではまだ client credential がないので、必ず更新と保存が行われる
prepareClientCredential(apiHost, clientInfo, clientName)
@ -291,6 +295,7 @@ class AuthMastodon(override val client: TootApiClient) : AuthBase() {
val accessToken = tokenInfo.string("access_token")
?.notEmpty() ?: error("can't parse access token.")
if(DEBUG_AUTH) log.i("DEBUG_AUTH accessToken=${accessToken}")
val accountJson = verifyAccount(
accessToken = accessToken,

View File

@ -115,7 +115,7 @@ object AppDataExporter {
writer.name(jsonKey)
writer.beginArray()
appDatabase.rawQuery("select from $table", emptyArray()).use { cursor ->
appDatabase.rawQuery("select * from $table", emptyArray()).use { cursor ->
val names = ArrayList<String>()
val column_count = cursor.columnCount
for (i in 0 until column_count) {

View File

@ -68,7 +68,7 @@ class Column(
val account_db_id = src.long(ColumnEncoder.KEY_ACCOUNT_ROW_ID) ?: -1L
return if (account_db_id > 0) {
daoSavedAccount.loadAccount(account_db_id)
?: error("missing account")
?: error("missing account for db_id $account_db_id")
} else {
SavedAccount.na
}

View File

@ -9,6 +9,7 @@ import jp.juggler.subwaytooter.api.entity.TootAccountRef
import jp.juggler.subwaytooter.column.Column
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.withCaption
import jp.juggler.util.ui.scan
abstract class ViewHolderHeaderBase(viewRoot: View) : RecyclerView.ViewHolder(viewRoot) {
@ -28,15 +29,18 @@ abstract class ViewHolderHeaderBase(viewRoot: View) : RecyclerView.ViewHolder(vi
if (v is Button) {
// ボタンは太字なので触らない
} else if (v is TextView) {
v.typeface = ActMain.timelineFont
try {
activity.timelineFontSizeSp
.takeIf { it.isFinite() }
?.let { v.textSize = it }
activity.timelineFontSizeSp
.takeIf { it.isFinite() }
?.let { v.textSize = it }
activity.timelineSpacing
?.let { v.setLineSpacing(0f, it) }
activity.timelineSpacing
?.let { v.setLineSpacing(0f, it) }
} catch (ex: NullPointerException) {
// 非null型なのになぜかnull例外が出る
log.w(ex.withCaption("can't read timelineFontSizeSp, timelineSpacing"))
}
}
} catch (ex: Throwable) {
log.e(ex, "can't initialize text styles.")

View File

@ -91,6 +91,7 @@ internal class ViewHolderHeaderInstance(
btnExplore.isEnabledAlpha = false
tvConfiguration.text = ""
tvFedibirdCapacities.text = ""
tvPlelomaFeatures.text = ""
} else {
val domain = instance.apDomain
btnInstance.text = when {
@ -163,6 +164,8 @@ internal class ViewHolderHeaderInstance(
instance.configuration?.toString(1, sort = true) ?: ""
tvFedibirdCapacities.text =
instance.fedibirdCapabilities?.sorted()?.joinToString("\n") ?: ""
tvPlelomaFeatures.text=
instance.pleromaFeatures?.sorted()?.joinToString("\n") ?: ""
}
tvHandshake.text = when (val handshake = column.handshake) {

View File

@ -33,12 +33,13 @@ abstract class PushBase {
protected abstract val daoStatus: AccountNotificationStatus.Access
// 購読の確認と更新
// 失敗したらエラーメッセージを返す。成功したらnull
abstract suspend fun updateSubscription(
subLog: SubscriptionLogger,
account: SavedAccount,
willRemoveSubscription: Boolean,
forceUpdate:Boolean,
)
):String?
// プッシュメッセージのJSONデータを通知用に整形
abstract suspend fun formatPushMessage(

View File

@ -8,12 +8,16 @@ import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.ApiError
import jp.juggler.subwaytooter.api.TootApiCallback
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.auth.AuthMastodon
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.push.ApiPushMastodon
import jp.juggler.subwaytooter.pref.PrefDevice
import jp.juggler.subwaytooter.pref.lazyContext
import jp.juggler.subwaytooter.pref.prefDevice
import jp.juggler.subwaytooter.table.*
import jp.juggler.subwaytooter.table.AccountNotificationStatus
import jp.juggler.subwaytooter.table.PushMessage
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.appDatabase
import jp.juggler.util.data.*
import jp.juggler.util.log.LogCategory
import jp.juggler.util.time.parseTimeIso8601
@ -41,26 +45,25 @@ class PushMastodon(
account: SavedAccount,
willRemoveSubscription: Boolean,
forceUpdate: Boolean,
) {
): String? {
val deviceHash = deviceHash(account)
val newUrl = snsCallbackUrl(account) // appServerHashを参照する
if (newUrl.isNullOrEmpty()) {
if (willRemoveSubscription) {
val msg =
lazyContext.getString(R.string.push_subscription_app_server_hash_missing_but_ok)
subLog.i(msg)
} else {
val msg =
lazyContext.getString(R.string.push_subscription_app_server_hash_missing_error)
subLog.e(msg)
daoAccountNotificationStatus.updateSubscriptionError(
account.acct,
msg
return when {
willRemoveSubscription -> {
val msg = lazyContext.getString(
R.string.push_subscription_app_server_hash_missing_but_ok
)
subLog.i(msg)
null
}
else -> lazyContext.getString(
R.string.push_subscription_app_server_hash_missing_error
)
}
return
}
if (AuthMastodon.DEBUG_AUTH) log.i("DEBUG_AUTH bearerAccessToken=${account.bearerAccessToken} ${account.acct}")
val oldSubscription = try {
api.getPushSubscription(account)
} catch (ex: Throwable) {
@ -90,16 +93,12 @@ class PushMastodon(
}
}
if (params["dh"] != deviceHash && !isOldSubscription(account, oldEndpointUrl)) {
// この端末で作成した購読ではない。
// TODO: 古い形式のURLを移行できないか
log.w("deviceHash not match. keep it for other devices. ${account.acct} $oldEndpointUrl")
subLog.e(R.string.push_subscription_exists_but_not_created_by_this_device)
daoAccountNotificationStatus.updateSubscriptionError(
account.acct,
context.getString(R.string.push_subscription_exists_but_not_created_by_this_device)
return context.getString(
R.string.push_subscription_exists_but_not_created_by_this_device
)
return
}
}
}
@ -114,14 +113,22 @@ class PushMastodon(
api.deletePushSubscription(account)
}
}
return
return null
}
val newAlerts = account.alerts()
// サーバのバージョンを見て、サーバの知らないalertを無視して比較する
val client = TootApiClient(context, callback = object : TootApiCallback {
override suspend fun isApiCancelled(): Boolean = !coroutineContext.isActive
})
client.account = account
val ti = TootInstance.getExOrThrow(client)
val newAlerts = account.alerts(ti)
val isSameAlert = isSameAlerts(
subLog = subLog,
account = account,
ti = ti,
oldSubscriptionJson = oldSubscription,
newAlerts = newAlerts,
)
@ -135,7 +142,7 @@ class PushMastodon(
) {
// 現在の更新を使い続ける
subLog.i(R.string.push_subscription_keep_using)
return
return null
}
if (newUrl == oldEndpointUrl) {
@ -179,6 +186,7 @@ class PushMastodon(
)
subLog.i(R.string.push_subscription_completed)
}
return null
}
private fun isOldSubscription(account: SavedAccount, url: String): Boolean {
@ -189,7 +197,7 @@ class PushMastodon(
// /{flags }
// /{ client identifier}
val clientIdentifierOld = url.toHttpUrlOrNull()?.pathSegments?.elementAtOrNull(4)
val clientIdentifierOld = url.toHttpUrlOrNull()?.pathSegments?.elementAtOrNull(4)
?: return false
val installId = prefDevice.installIdV1?.notEmpty() ?: return false
val accessToken = account.bearerAccessToken?.notEmpty() ?: return false
@ -197,9 +205,10 @@ class PushMastodon(
return clientIdentifier == clientIdentifierOld
}
private suspend fun isSameAlerts(
private fun isSameAlerts(
subLog: SubscriptionLogger,
account: SavedAccount,
ti: TootInstance,
oldSubscriptionJson: JsonObject?,
newAlerts: JsonObject,
): Boolean {
@ -210,7 +219,7 @@ class PushMastodon(
// flagsの値が変わりendpoint URLも変わった状態で購読を自動更新してしまう
// しかしそのタイミングではサーバは古いのでサーバ側の購読内容は変化しなかった。
// サーバ上の購読アラートのリスト
// 既存の購読のアラートのリスト
var alertsOld = oldSubscription.alerts.entries
.mapNotNull { if (it.value) it.key else null }
.sorted()
@ -221,26 +230,13 @@ class PushMastodon(
.sorted()
// 両方に共通するアラートは除去する
// サーバが知らないアラートは除去する
val bothHave = alertsOld.filter { alertsNew.contains(it) }
alertsOld = alertsOld.filter { !bothHave.contains(it) }
alertsNew = alertsNew.filter { !bothHave.contains(it) }
// サーバのバージョンを調べる前に、この時点でalertsが一致するか確認する
if (alertsOld.joinToString(",") == alertsNew.joinToString(",")) {
return true
}
// サーバのバージョンを見て、サーバの知らないalertを無視して比較する
val client = TootApiClient(context, callback = object : TootApiCallback {
override suspend fun isApiCancelled(): Boolean = !coroutineContext.isActive
})
client.account = account
val ti = TootInstance.getExOrThrow(client)
alertsOld = alertsOld.knownOnly(account, ti)
alertsNew = alertsNew.knownOnly(account, ti)
alertsOld =
alertsOld.filter { !bothHave.contains(it) }.knownOnly(account, ti)
alertsNew =
alertsNew.filter { !bothHave.contains(it) }.knownOnly(account, ti)
return if (alertsOld.joinToString(",") == alertsNew.joinToString(",")) {
log.i("${account.acct}: same alerts(2)")
true
} else {
log.i("${account.acct}: changed. old=${alertsOld.sorted()}, new=${alertsNew.sorted()}")
@ -249,32 +245,41 @@ class PushMastodon(
}
}
private fun SavedAccount.alerts() = JsonObject().apply {
private fun SavedAccount.alerts(ti: TootInstance) = JsonObject().also { dst ->
// Mastodon's Notification::TYPES in
// in https://github.com/mastodon/mastodon/blob/main/app/models/notification.rb#L30
put(TootNotification.TYPE_ADMIN_REPORT, notificationFollow)
put(TootNotification.TYPE_ADMIN_SIGNUP, notificationFollow) // 設定項目不足
put(TootNotification.TYPE_FAVOURITE, notificationFavourite)
put(TootNotification.TYPE_FOLLOW, notificationFollow)
put(TootNotification.TYPE_FOLLOW_REQUEST, notificationFollowRequest)
put(TootNotification.TYPE_MENTION, notificationMention)
put(TootNotification.TYPE_POLL, notificationVote)
put(TootNotification.TYPE_REBLOG, notificationBoost)
put(TootNotification.TYPE_STATUS, notificationPost)
put(TootNotification.TYPE_UPDATE, notificationUpdate)
dst[TootNotification.TYPE_ADMIN_REPORT] = notificationFollow
dst[TootNotification.TYPE_ADMIN_SIGNUP] = notificationFollow // 設定項目不足
dst[TootNotification.TYPE_FAVOURITE] = notificationFavourite
dst[TootNotification.TYPE_FOLLOW] = notificationFollow
dst[TootNotification.TYPE_FOLLOW_REQUEST] = notificationFollowRequest
dst[TootNotification.TYPE_MENTION] = notificationMention
dst[TootNotification.TYPE_POLL] = notificationVote
dst[TootNotification.TYPE_REBLOG] = notificationBoost
dst[TootNotification.TYPE_STATUS] = notificationPost
dst[TootNotification.TYPE_UPDATE] = notificationUpdate
// fedibird拡張
// https://github.com/fedibird/mastodon/blob/fedibird/app/controllers/api/v1/push/subscriptions_controller.rb#L55
// https://github.com/fedibird/mastodon/blob/fedibird/app/models/notification.rb
put(TootNotification.TYPE_EMOJI_REACTION, notificationReaction)
put(TootNotification.TYPE_SCHEDULED_STATUS, notificationPost) // 設定項目不足
put(TootNotification.TYPE_STATUS_REFERENCE, notificationStatusReference)
if (!ti.pleromaFeatures.isNullOrEmpty()) {
dst[TootNotification.TYPE_EMOJI_REACTION_PLEROMA] = notificationReaction
} else if (!ti.fedibirdCapabilities.isNullOrEmpty()) {
dst[TootNotification.TYPE_EMOJI_REACTION] = notificationReaction
}
dst[TootNotification.TYPE_SCHEDULED_STATUS] = notificationPost // 設定項目不足
dst[TootNotification.TYPE_STATUS_REFERENCE] = notificationStatusReference
}
// サーバが知らないアラート種別は比較対象から除去する
private fun Iterable<String>.knownOnly(account: SavedAccount, ti: TootInstance) = filter {
private fun Iterable<String>.knownOnly(
account: SavedAccount,
ti: TootInstance,
) = filter {
when (it) {
// TYPE_ADMIN_SIGNUP, TYPE_UPDATE はalertから読めない時期があった。4.0.0以降なら大丈夫だろう
TootNotification.TYPE_ADMIN_REPORT -> ti.versionGE(TootInstance.VERSION_4_0_0)
TootNotification.TYPE_ADMIN_SIGNUP -> ti.versionGE(TootInstance.VERSION_3_5_0_rc1)
TootNotification.TYPE_ADMIN_SIGNUP -> ti.versionGE(TootInstance.VERSION_4_0_0)
TootNotification.TYPE_FAVOURITE -> true
TootNotification.TYPE_FOLLOW -> true
TootNotification.TYPE_FOLLOW_REQUEST -> ti.versionGE(TootInstance.VERSION_3_1_0_rc1)
@ -282,12 +287,15 @@ class PushMastodon(
TootNotification.TYPE_POLL -> ti.versionGE(TootInstance.VERSION_2_8_0_rc1)
TootNotification.TYPE_REBLOG -> true
TootNotification.TYPE_STATUS -> ti.versionGE(TootInstance.VERSION_3_3_0_rc1)
TootNotification.TYPE_UPDATE -> ti.versionGE(TootInstance.VERSION_3_5_0_rc1)
TootNotification.TYPE_UPDATE -> ti.versionGE(TootInstance.VERSION_4_0_0)
//////////////////////
// Fedibird拡張
TootNotification.TYPE_EMOJI_REACTION,
-> InstanceCapability.emojiReaction(account, ti)
// pleromaの絵文字リアクションはalertに指定できない
TootNotification.TYPE_EMOJI_REACTION_PLEROMA,
-> InstanceCapability.emojiReaction(account, ti)

View File

@ -42,7 +42,7 @@ class PushMisskey(
account: SavedAccount,
willRemoveSubscription: Boolean,
forceUpdate: Boolean,
) {
): String? {
val newUrl = snsCallbackUrl(account)
val lastEndpointUrl = daoStatus.lastEndpointUrl(account.acct)
@ -83,29 +83,28 @@ class PushMisskey(
subLog.i(R.string.push_subscription_off_unread_notification)
}
}
return
return null
} else {
// 古い購読はあったが、削除したい
api.deletePushSubscription(account, lastEndpointUrl)
daoStatus.deleteLastEndpointUrl(account.acct)
if (willRemoveSubscription) {
subLog.i(R.string.push_subscription_delete_current)
return
return null
}
}
}
}
if (newUrl == null) {
if (willRemoveSubscription) {
subLog.i(R.string.push_subscription_app_server_hash_missing_but_ok)
} else {
subLog.e(R.string.push_subscription_app_server_hash_missing_error)
daoAccountNotificationStatus.updateSubscriptionError(
account.acct,
context.getString(R.string.push_subscription_app_server_hash_missing_error)
return when {
willRemoveSubscription -> {
subLog.i(R.string.push_subscription_app_server_hash_missing_but_ok)
null
}
else -> context.getString(
R.string.push_subscription_app_server_hash_missing_error
)
}
return
} else if (willRemoveSubscription) {
// 購読を解除したい。
// hasEmptySubscription が真なら購読はないが、
@ -117,7 +116,7 @@ class PushMisskey(
subLog.i(R.string.push_subscription_is_not_required_delete_secret_keys)
}
}
return
return null
}
// 鍵がなければ作る
@ -163,6 +162,7 @@ class PushMisskey(
subLog.i(R.string.push_subscription_server_key_saved)
}
subLog.i(R.string.push_subscription_completed)
return null
}
private fun isOldSubscription(account: SavedAccount, url: String): Boolean {
@ -173,7 +173,7 @@ class PushMisskey(
// /{flags }
// /{ client identifier}
val clientIdentifierOld = url.toHttpUrlOrNull()?.pathSegments?.elementAtOrNull(4)
val clientIdentifierOld = url.toHttpUrlOrNull()?.pathSegments?.elementAtOrNull(4)
?: return false
val installId = prefDevice.installIdV1?.notEmpty() ?: return false
val accessToken = account.misskeyApiToken?.notEmpty() ?: return false

View File

@ -36,12 +36,15 @@ import jp.juggler.util.os.applicationContextSafe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.unifiedpush.android.connector.UnifiedPush
import java.lang.ref.WeakReference
import java.security.Provider
import java.util.concurrent.TimeUnit
import kotlin.math.min
private val log = LogCategory("PushRepo")
@ -90,6 +93,8 @@ class PushRepo(
private val ncPushMessage = NotificationChannels.PushMessage
var refReporter: WeakReference<SuspendProgress.Reporter>? = null
var subscriptionMutex = Mutex()
}
private val pushMisskey by lazy {
@ -116,7 +121,7 @@ class PushRepo(
* UPでプッシュサービスを選ぶと呼ばれる
*/
suspend fun switchDistributor(
pushDistributor: String,
pushDistributor: String?,
reporter: SuspendProgress.Reporter,
) {
val timeSwitchStart = System.currentTimeMillis()
@ -139,6 +144,10 @@ class PushRepo(
fcmHandler.deleteFcmToken()
when (pushDistributor) {
null, "" -> {
// 特に変更しない(アクセストークン更新時に呼ばれる
enqueueRegisterEndpoint(context)
}
PrefDevice.PUSH_DISTRIBUTOR_NONE -> {
// 購読解除
reporter.setMessage("SubscriptionUpdateService.launch")
@ -161,20 +170,19 @@ class PushRepo(
}
val timeout = timeSwitchStart + TimeUnit.SECONDS.toMillis(20)
while (true) {
val now = System.currentTimeMillis()
if (now >= timeout) {
reporter.setMessage("timeout")
delay(888L)
break
}
if (PushWorker.timeEndRegisterEndpoint.get() >= timeSwitchStart ||
PushWorker.timeEndUpEndpoint.get() >= timeSwitchStart
) {
reporter.setMessage("complete")
delay(888L)
// complete
break
}
delay(1000L)
val remain = min(1000L, timeout - System.currentTimeMillis())
if (remain <= 0) {
reporter.setMessage("timeout")
delay(666L)
break
}
delay(remain)
}
}
@ -215,155 +223,158 @@ class PushRepo(
suspend fun registerEndpoint(
keepAliveMode: Boolean,
) {
log.i("registerEndpoint: keepAliveMode=$keepAliveMode")
subscriptionMutex.withLock {
log.i("registerEndpoint: keepAliveMode=$keepAliveMode")
// 古いFCMトークンの情報はアプリサーバ側で勝手に消えるはず
try {
// 期限切れのUPエンドポイントがあればそれ経由の中継を解除する
prefDevice.fcmTokenExpired.notEmpty()?.let {
refReporter?.get()?.setMessage("期限切れのFCMデバイストークンをアプリサーバから削除しています")
log.i("remove fcmTokenExpired")
apiAppServer.endpointRemove(fcmToken = it)
prefDevice.fcmTokenExpired = null
// 古いFCMトークンの情報はアプリサーバ側で勝手に消えるはず
try {
// 期限切れのUPエンドポイントがあればそれ経由の中継を解除する
prefDevice.fcmTokenExpired.notEmpty()?.let {
refReporter?.get()?.setMessage("期限切れのFCMデバイストークンをアプリサーバから削除しています")
log.i("remove fcmTokenExpired")
apiAppServer.endpointRemove(fcmToken = it)
prefDevice.fcmTokenExpired = null
}
} catch (ex: Throwable) {
log.w(ex, "can't forgot fcmTokenExpired")
}
} catch (ex: Throwable) {
log.w(ex, "can't forgot fcmTokenExpired")
}
try {
// 期限切れのUPエンドポイントがあればそれ経由の中継を解除する
prefDevice.upEndpointExpired.notEmpty()?.let {
refReporter?.get()?.setMessage("期限切れのUnifiedPushエンドポイントをアプリサーバから削除しています")
log.i("remove upEndpointExpired")
apiAppServer.endpointRemove(upUrl = it)
prefDevice.upEndpointExpired = null
try {
// 期限切れのUPエンドポイントがあればそれ経由の中継を解除する
prefDevice.upEndpointExpired.notEmpty()?.let {
refReporter?.get()?.setMessage("期限切れのUnifiedPushエンドポイントをアプリサーバから削除しています")
log.i("remove upEndpointExpired")
apiAppServer.endpointRemove(upUrl = it)
prefDevice.upEndpointExpired = null
}
} catch (ex: Throwable) {
log.w(ex, "can't forgot upEndpointExpired")
}
} catch (ex: Throwable) {
log.w(ex, "can't forgot upEndpointExpired")
}
val realAccounts = daoSavedAccount.loadRealAccounts()
.filter { !it.isPseudo && it.isConfirmed }
val realAccounts = daoSavedAccount.loadRealAccounts()
.filter { !it.isPseudo && it.isConfirmed }
val accts = realAccounts.map { it.acct }
val accts = realAccounts.map { it.acct }
// map of acctHash to account
val acctHashMap = daoStatus.updateAcctHash(accts)
if (acctHashMap.isEmpty()) {
log.w("acctHashMap is empty. no need to update register endpoint")
return
}
if (keepAliveMode) {
val lastUpdated = prefDevice.timeLastEndpointRegister
val now = System.currentTimeMillis()
if (now - lastUpdated < TimeUnit.DAYS.toMillis(3)) {
log.i("lazeMode: skip re-registration.")
// map of acctHash to account
val acctHashMap = daoStatus.updateAcctHash(accts)
if (acctHashMap.isEmpty()) {
log.w("acctHashMap is empty. no need to update register endpoint")
return
}
}
var willRemoveSubscription = false
if (keepAliveMode) {
val lastUpdated = prefDevice.timeLastEndpointRegister
val now = System.currentTimeMillis()
if (now - lastUpdated < TimeUnit.DAYS.toMillis(3)) {
log.i("lazeMode: skip re-registration.")
}
}
// アプリサーバにendpointを登録する
refReporter?.get()?.setMessage("アプリサーバにプッシュサービスの情報を送信しています")
var willRemoveSubscription = false
if (!fcmHandler.hasFcm && prefDevice.pushDistributor == PrefDevice.PUSH_DISTRIBUTOR_FCM) {
log.w("fmc selected, but this is noFcm build. unset distributer.")
prefDevice.pushDistributor = null
}
// アプリサーバにendpointを登録する
refReporter?.get()?.setMessage("アプリサーバにプッシュサービスの情報を送信しています")
val acctHashList = acctHashMap.keys.toList()
if (!fcmHandler.hasFcm && prefDevice.pushDistributor == PrefDevice.PUSH_DISTRIBUTOR_FCM) {
log.w("fmc selected, but this is noFcm build. unset distributer.")
prefDevice.pushDistributor = null
}
val json = when (prefDevice.pushDistributor) {
null, "" -> when {
fcmHandler.hasFcm -> {
log.i("registerEndpoint dist=FCM(default), acctHashList=${acctHashList.size}")
val acctHashList = acctHashMap.keys.toList()
val json = when (prefDevice.pushDistributor) {
null, "" -> when {
fcmHandler.hasFcm -> {
log.i("registerEndpoint dist=FCM(default), acctHashList=${acctHashList.size}")
registerEndpointFcm(acctHashList)
}
else -> {
log.w("pushDistributor not selected. but can't select default distributor from background service.")
return
}
}
PrefDevice.PUSH_DISTRIBUTOR_NONE -> {
log.i("push distrobuter 'none' is selected. it will remove subscription.")
willRemoveSubscription = true
null
}
PrefDevice.PUSH_DISTRIBUTOR_FCM -> {
log.i("registerEndpoint dist=FCM, acctHashList=${acctHashList.size}")
registerEndpointFcm(acctHashList)
}
else -> {
log.w("pushDistributor not selected. but can't select default distributor from background service.")
return
log.i("registerEndpoint dist=${prefDevice.pushDistributor}, acctHashList=${acctHashList.size}")
registerEndpointUnifiedPush(acctHashList)
}
}
PrefDevice.PUSH_DISTRIBUTOR_NONE -> {
log.i("push distrobuter 'none' is selected. it will remove subscription.")
willRemoveSubscription = true
null
}
PrefDevice.PUSH_DISTRIBUTOR_FCM -> {
log.i("registerEndpoint dist=FCM, acctHashList=${acctHashList.size}")
registerEndpointFcm(acctHashList)
}
else -> {
log.i("registerEndpoint dist=${prefDevice.pushDistributor}, acctHashList=${acctHashList.size}")
registerEndpointUnifiedPush(acctHashList)
}
}
when {
json.isNullOrEmpty() ->
log.i("no information of appServerHash.")
when {
json.isNullOrEmpty() ->
log.i("no information of appServerHash.")
else -> {
// acctHash => appServerHash のマップが返ってくる
// ステータスに覚える
var saveCount = 0
for (acctHash in json.keys) {
val acct = acctHashMap[acctHash] ?: continue
val appServerHash = json.string(acctHash) ?: continue
++saveCount
val status = daoStatus.loadOrCreate(acct)
if (status.appServerHash == appServerHash) continue
daoStatus.saveAppServerHash(status.id, appServerHash)
else -> {
// acctHash => appServerHash のマップが返ってくる
// ステータスに覚える
var saveCount = 0
for (acctHash in json.keys) {
val acct = acctHashMap[acctHash] ?: continue
val appServerHash = json.string(acctHash) ?: continue
++saveCount
val status = daoStatus.loadOrCreate(acct)
if (status.appServerHash == appServerHash) continue
daoStatus.saveAppServerHash(status.id, appServerHash)
}
log.i("appServerHash updated. saveCount=$saveCount")
}
log.i("appServerHash updated. saveCount=$saveCount")
}
}
realAccounts.forEach { account ->
val subLog = object : PushBase.SubscriptionLogger {
override val context = this@PushRepo.context
override fun i(msg: String) {
log.i("[${account.acct}]$msg")
realAccounts.forEach { account ->
val subLog = object : PushBase.SubscriptionLogger {
override val context = this@PushRepo.context
override fun i(msg: String) {
log.i("[${account.acct}]$msg")
refReporter?.get()?.setMessage("[${account.acct}]$msg")
}
override fun e(msg: String) {
log.e("[${account.acct}]$msg")
refReporter?.get()?.setMessage("[${account.acct}]$msg")
daoAccountNotificationStatus.updateSubscriptionError(
account.acct,
msg
)
}
override fun e(ex: Throwable, msg: String) {
log.e(ex, "[${account.acct}]$msg")
refReporter?.get()?.setMessage("[${account.acct}]$msg")
daoAccountNotificationStatus.updateSubscriptionError(
account.acct,
ex.withCaption(msg)
)
}
}
override fun e(msg: String) {
log.e("[${account.acct}]$msg")
daoAccountNotificationStatus.updateSubscriptionError(
account.acct,
msg
)
}
override fun e(ex: Throwable, msg: String) {
log.e(ex, "[${account.acct}]$msg")
daoAccountNotificationStatus.updateSubscriptionError(
account.acct,
ex.withCaption(msg)
try {
val errMsg = pushBase(account).updateSubscription(
subLog = subLog,
account = account,
willRemoveSubscription = willRemoveSubscription || !account.isRequiredPushSubscription(),
forceUpdate = false,
)
daoAccountNotificationStatus.updateSubscriptionError(account.acct, errMsg)
if (errMsg != null) {
subLog.e(errMsg)
}
} catch (ex: Throwable) {
log.e(ex, "updateSubscription failed.")
val errMsg = ex.withCaption()
subLog.e(errMsg)
daoAccountNotificationStatus.updateSubscriptionError(account.acct, errMsg)
}
}
try {
refReporter?.get()?.setMessage("${account.acct.pretty} のWebPush購読を更新しています")
daoAccountNotificationStatus.updateSubscriptionError(
account.acct,
null
)
pushBase(account).updateSubscription(
subLog = subLog,
account = account,
willRemoveSubscription = willRemoveSubscription || !account.isRequiredPushSubscription(),
forceUpdate = false,
)
} catch (ex: Throwable) {
subLog.e(ex, "updateSubscription failed.")
daoAccountNotificationStatus.updateSubscriptionError(
account.acct,
ex.withCaption()
)
}
prefDevice.timeLastEndpointRegister = System.currentTimeMillis()
}
prefDevice.timeLastEndpointRegister = System.currentTimeMillis()
}
private suspend fun registerEndpointUnifiedPush(acctHashList: List<String>) =
@ -408,12 +419,25 @@ class PushRepo(
willRemoveSubscription: Boolean,
forceUpdate: Boolean = false,
) {
pushBase(account).updateSubscription(
subLog = subLog,
account = account,
willRemoveSubscription = willRemoveSubscription || !account.isRequiredPushSubscription(),
forceUpdate = forceUpdate,
)
subscriptionMutex.withLock {
try {
val errMsg = pushBase(account).updateSubscription(
subLog = subLog,
account = account,
willRemoveSubscription = willRemoveSubscription || !account.isRequiredPushSubscription(),
forceUpdate = forceUpdate,
)
daoAccountNotificationStatus.updateSubscriptionError(account.acct, errMsg)
if (errMsg != null) {
subLog.e(errMsg)
}
} catch (ex: Throwable) {
log.e(ex, "updateSubscription failed.")
val errMsg = ex.withCaption()
subLog.e(errMsg)
daoAccountNotificationStatus.updateSubscriptionError(account.acct, errMsg)
}
}
}
private fun pushBase(account: SavedAccount) = when {

View File

@ -38,6 +38,7 @@ class SavedAccount(
apiHostArg: String? = null,
apDomainArg: String? = null,
var accountJson:JsonObject? = null,
var confirmBoost: Boolean = false,
var confirmFavourite: Boolean = false,
var confirmFollow: Boolean = false,
@ -119,7 +120,7 @@ class SavedAccount(
var notificationAccentColor by jsonDelegates.int
init {
log.i("ctor acctArg $acctArg")
// log.i("ctor acctArg $acctArg")
// acctArg はMastodonの生のやつで、ドメイン部分がない場合がある
// Acct.parse はHost部分がnullのacctになるかもしれない
@ -142,7 +143,8 @@ class SavedAccount(
acctArg = cursor.getString(COL_USER), // acct
apiHostArg = cursor.getStringOrNull(COL_HOST), // host
apDomainArg = cursor.getStringOrNull(COL_DOMAIN), // host
misskeyVersion = cursor.getInt(COL_MISSKEY_VERSION),
accountJson = cursor.getStringOrNull(COL_ACCOUNT)?.decodeJsonObject(),
confirmBoost = cursor.getBoolean(COL_CONFIRM_BOOST),
confirmFavourite = cursor.getBoolean(COL_CONFIRM_FAVOURITE),
confirmFollow = cursor.getBoolean(COL_CONFIRM_FOLLOW),
@ -177,6 +179,9 @@ class SavedAccount(
tokenJson = cursor.getString(COL_TOKEN).decodeJsonObject(),
visibility = TootVisibility.parseSavedVisibility(cursor.getStringOrNull(COL_VISIBILITY))
?: TootVisibility.Public,
misskeyVersion = cursor.getInt(COL_MISSKEY_VERSION),
// lastNotificationError = cursor.getStringOrNull(COL_LAST_NOTIFICATION_ERROR)
// last_push_endpoint = cursor.getStringOrNull(COL_LAST_PUSH_ENDPOINT)
// last_subscription_error = cursor.getStringOrNull(COL_LAST_SUBSCRIPTION_ERROR)
@ -185,9 +190,7 @@ class SavedAccount(
// register_time = cursor.getLong(COL_REGISTER_TIME)
) {
val strAccount = cursor.getString(COL_ACCOUNT)
val jsonAccount = strAccount.decodeJsonObject()
loginAccount = if (jsonAccount["id"] == null) {
loginAccount = if (accountJson?.get("id") == null) {
null // 疑似アカウント
} else {
TootParser(
@ -197,8 +200,8 @@ class SavedAccount(
apDomainArg = this@SavedAccount.apDomain,
misskeyVersion = misskeyVersion
)
).account(jsonAccount)
?: error("missing loginAccount for $strAccount")
).account(accountJson)
?: error("missing loginAccount for $accountJson")
}
}
@ -468,6 +471,7 @@ class SavedAccount(
if (isInvalidId) error("saveSetting: missing db_id")
ContentValues().apply {
put(COL_ACCOUNT,accountJson?.toString())
put(COL_CONFIRM_BOOST, confirmBoost)
put(COL_CONFIRM_FAVOURITE, confirmFavourite)
put(COL_CONFIRM_FOLLOW, confirmFollow)
@ -499,7 +503,9 @@ class SavedAccount(
put(COL_NOTIFICATION_VOTE, notificationVote)
put(COL_PUSH_POLICY, pushPolicy)
// put(COL_SOUND_URI, soundUri)
put(COL_TOKEN,tokenJson?.toString())
put(COL_VISIBILITY, visibility.id.toString())
put(COL_MISSKEY_VERSION,misskeyVersion)
}.let { db.update(table, it, "$COL_ID=?", arrayOf(db_id.toString())) }
}
}
@ -512,7 +518,7 @@ class SavedAccount(
// DBから削除されてるかもしれない
val b = newData ?: loadAccount(db_id) ?: return
this.accountJson = b.accountJson
this.confirmBoost = b.confirmBoost
this.confirmFavourite = b.confirmFavourite
this.confirmPost = b.confirmPost
@ -525,31 +531,32 @@ class SavedAccount(
this.dontHideNsfw = b.dontHideNsfw
this.dontShowTimeout = b.dontShowTimeout
this.expandCw = b.expandCw
this.extraJson = b.extraJson
this.imageMaxMegabytes = b.imageMaxMegabytes
this.imageResize = b.imageResize
this.lang = b.lang
this.movieMaxMegabytes = b.movieMaxMegabytes
this.movieTranscodeBitrate = b.movieTranscodeBitrate
this.movieTranscodeFramerate = b.movieTranscodeFramerate
this.movieTranscodeMode = b.movieTranscodeMode
this.movieTranscodeSquarePixels = b.movieTranscodeSquarePixels
this.movieMaxMegabytes = b.movieMaxMegabytes
this.notificationBoost = b.notificationBoost
this.notificationFavourite = b.notificationFavourite
this.notificationFollow = b.notificationFollow
this.notificationFollowRequest = b.notificationFollowRequest
this.notificationMention = b.notificationMention
this.notificationPost = b.notificationPost
this.notificationPullEnable = b.notificationPullEnable
this.notificationPushEnable = b.notificationPushEnable
this.notificationReaction = b.notificationReaction
this.notificationStatusReference = b.notificationStatusReference
this.notificationUpdate = b.notificationUpdate
this.notificationVote = b.notificationVote
this.pushPolicy = b.pushPolicy
// this.soundUri = b.soundUri
this.tokenJson = b.tokenJson
this.visibility = b.visibility
this.notificationPushEnable = b.notificationPushEnable
this.notificationPullEnable = b.notificationPullEnable
// this.soundUri = b.soundUri
this.misskeyVersion = b.misskeyVersion
}
}

View File

@ -5,87 +5,76 @@
android:layout_height="wrap_content"
android:descendantFocusability="blocksDescendants"
android:orientation="vertical"
android:paddingBottom="128dp"
android:paddingEnd="12dp"
android:paddingStart="12dp"
android:paddingTop="12dp"
android:paddingEnd="12dp"
android:paddingBottom="128dp"
>
<View style="@style/setting_divider"/>
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/instance"
/>
android:text="@string/instance" />
<Button
android:id="@+id/btnInstance"
style="@style/setting_row_button"
/>
style="@style/setting_row_button" />
<View style="@style/setting_divider"/>
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/version"
/>
android:text="@string/version" />
<LinearLayout style="@style/setting_row_form">
<TextView
android:id="@+id/tvVersion"
style="@style/setting_horizontal_stretch"
/>
style="@style/setting_horizontal_stretch" />
</LinearLayout>
<View style="@style/setting_divider"/>
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/title"
/>
android:text="@string/title" />
<LinearLayout style="@style/setting_row_form">
<TextView
android:id="@+id/tvTitle"
style="@style/setting_horizontal_stretch"
/>
style="@style/setting_horizontal_stretch" />
</LinearLayout>
<View style="@style/setting_divider"/>
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/email"
/>
android:text="@string/email" />
<Button
android:id="@+id/btnEmail"
style="@style/setting_row_button"
/>
style="@style/setting_row_button" />
<View style="@style/setting_divider"/>
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/contact"
/>
android:text="@string/contact" />
<Button
android:id="@+id/btnContact"
style="@style/setting_row_button"
/>
style="@style/setting_row_button" />
<View style="@style/setting_divider"/>
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/languages"
/>
android:text="@string/languages" />
<LinearLayout style="@style/setting_row_form">
@ -94,16 +83,14 @@
style="@style/setting_horizontal_stretch"
android:background="@drawable/btn_bg_transparent_round6dp"
android:gravity="start|center_vertical"
android:textAllCaps="false"
/>
android:textAllCaps="false" />
</LinearLayout>
<View style="@style/setting_divider"/>
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/invites_enabled"
/>
android:text="@string/invites_enabled" />
<LinearLayout style="@style/setting_row_form">
@ -112,60 +99,52 @@
style="@style/setting_horizontal_stretch"
android:background="@drawable/btn_bg_transparent_round6dp"
android:gravity="start|center_vertical"
android:textAllCaps="false"
/>
android:textAllCaps="false" />
</LinearLayout>
<View style="@style/setting_divider"/>
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/user_count"
/>
android:text="@string/user_count" />
<LinearLayout style="@style/setting_row_form">
<TextView
android:id="@+id/tvUserCount"
style="@style/setting_horizontal_stretch"
/>
style="@style/setting_horizontal_stretch" />
</LinearLayout>
<View style="@style/setting_divider"/>
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/toot_count"
/>
android:text="@string/toot_count" />
<LinearLayout style="@style/setting_row_form">
<TextView
android:id="@+id/tvTootCount"
style="@style/setting_horizontal_stretch"
/>
style="@style/setting_horizontal_stretch" />
</LinearLayout>
<View style="@style/setting_divider"/>
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/domain_count"
/>
android:text="@string/domain_count" />
<TextView
android:id="@+id/tvDomainCount"
style="@style/setting_row_form"
/>
style="@style/setting_row_form" />
<View style="@style/setting_divider"/>
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/thumbnail"
/>
android:text="@string/thumbnail" />
<LinearLayout style="@style/setting_row_form">
@ -173,94 +152,90 @@
android:id="@+id/ivThumbnail"
android:layout_width="match_parent"
android:layout_height="120dp"
android:scaleType="fitCenter"
/>
android:scaleType="fitCenter" />
</LinearLayout>
<View style="@style/setting_divider"/>
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/short_description"
/>
android:text="@string/short_description" />
<jp.juggler.subwaytooter.view.MyTextView
android:id="@+id/tvShortDescription"
style="@style/setting_row_form"
/>
style="@style/setting_row_form" />
<View style="@style/setting_divider"/>
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/description"
/>
android:text="@string/description" />
<jp.juggler.subwaytooter.view.MyTextView
android:id="@+id/tvDescription"
style="@style/setting_row_form"
/>
style="@style/setting_row_form" />
<View style="@style/setting_divider"/>
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/links"
/>
android:text="@string/links" />
<Button
android:id="@+id/btnAbout"
style="@style/setting_row_button"
android:text="@string/top_page"
/>
android:text="@string/top_page" />
<Button
android:id="@+id/btnAboutMore"
style="@style/setting_row_button"
android:text="@string/about_this_instance"
/>
android:text="@string/about_this_instance" />
<Button
android:id="@+id/btnExplore"
style="@style/setting_row_button"
android:text="@string/profile_directory"
/>
<View style="@style/setting_divider"/>
android:text="@string/profile_directory" />
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/server_configuration"
/>
android:text="@string/server_configuration" />
<jp.juggler.subwaytooter.view.MyTextView
android:id="@+id/tvConfiguration"
style="@style/setting_row_form"
/>
<View style="@style/setting_divider"/>
style="@style/setting_row_form" />
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/fedibird_capacities"
/>
android:text="@string/fedibird_capacities" />
<jp.juggler.subwaytooter.view.MyTextView
android:id="@+id/tvFedibirdCapacities"
style="@style/setting_row_form"
/>
style="@style/setting_row_form" />
<View style="@style/setting_divider"/>
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/tls_handshake"
/>
android:text="@string/pleroma_features" />
<jp.juggler.subwaytooter.view.MyTextView
android:id="@+id/tvPlelomaFeatures"
style="@style/setting_row_form" />
<View style="@style/setting_divider" />
<TextView
style="@style/setting_row_label"
android:text="@string/tls_handshake" />
<jp.juggler.subwaytooter.view.MyTextView
android:id="@+id/tvHandshake"
style="@style/setting_row_form"
/>
style="@style/setting_row_form" />
<View style="@style/setting_divider"/>
<View style="@style/setting_divider" />
</LinearLayout>

View File

@ -1239,4 +1239,5 @@
<string name="manually_update">手動Manually update</string>
<string name="notification_push_distributor_disabled">通知のプッシュ配送サービスが選択されていません</string>
<string name="notification_accent_color">通知のアクセント色</string>
<string name="pleroma_features">Pleroma機能</string>
</resources>

View File

@ -1255,4 +1255,5 @@
<string name="manually_update">Manually update</string>
<string name="notification_push_distributor_disabled">Notification push distributor not selected.</string>
<string name="notification_accent_color">Notification accent color</string>
<string name="pleroma_features">Pleroma features</string>
</resources>