SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/push/PushMastodon.kt

385 lines
16 KiB
Kotlin
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package jp.juggler.subwaytooter.push
import android.content.Context
import jp.juggler.crypt.defaultSecurityProvider
import jp.juggler.crypt.encodeP256Dh
import jp.juggler.crypt.generateKeyPair
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.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.util.data.*
import jp.juggler.util.log.LogCategory
import jp.juggler.util.time.parseTimeIso8601
import kotlinx.coroutines.isActive
import java.security.Provider
import java.security.SecureRandom
import java.security.interfaces.ECPublicKey
import kotlin.coroutines.coroutineContext
private val log = LogCategory("PushMastodon")
class PushMastodon(
private val context: Context,
private val api: ApiPushMastodon,
private val provider: Provider =
defaultSecurityProvider,
override val prefDevice: PrefDevice =
lazyContext.prefDevice,
override val daoStatus: AccountNotificationStatus.Access =
AccountNotificationStatus.Access(appDatabase),
) : PushBase() {
override suspend fun updateSubscription(
subLog: SubscriptionLogger,
account: SavedAccount,
willRemoveSubscription: Boolean,
forceUpdate: Boolean,
) {
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
}
val oldSubscription = try {
api.getPushSubscription(account)
} catch (ex: Throwable) {
if ((ex as? ApiError)?.response?.code == 404) {
null
} else {
throw ex
}
}
log.i("${account.acct} oldSubscription=${oldSubscription}")
val oldEndpointUrl = oldSubscription?.string("endpoint")
when (oldEndpointUrl) {
// 購読がない。作ってもよい
null -> Unit
else -> {
val params = buildMap {
if (oldEndpointUrl.startsWith(appServerUrlPrefix)) {
oldEndpointUrl.substring(appServerUrlPrefix.length)
.split("/")
.forEach { pair ->
val cols = pair.split("_", limit = 2)
cols.elementAtOrNull(0)?.notEmpty()?.let { k ->
put(k, cols.elementAtOrNull(1) ?: "")
}
}
}
}
if (params["dh"] != deviceHash) {
// この端末で作成した購読ではない。
// 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)
return
}
}
}
if (willRemoveSubscription) {
when (oldSubscription) {
null -> {
subLog.i(R.string.push_subscription_is_not_required)
}
else -> {
subLog.i(R.string.push_subscription_delete_current)
api.deletePushSubscription(account)
}
}
return
}
val newAlerts = account.alerts()
val isSameAlert = isSameAlerts(
subLog = subLog,
account = account,
oldSubscriptionJson = oldSubscription,
newAlerts = newAlerts,
)
// https://github.com/mastodon/mastodon/pull/23210
// ポリシーの変更をチェックできるようになるのは4.1くらい?
val isSamePolicy = true // account.pushPolicy == oldSubscription.
if (!forceUpdate && isSameAlert && isSamePolicy &&
newUrl == oldEndpointUrl
) {
// 現在の更新を使い続ける
subLog.i(R.string.push_subscription_keep_using)
return
}
if (newUrl == oldEndpointUrl) {
subLog.i(R.string.push_subscription_exists_updateing)
api.updatePushSubscriptionData(
a = account,
alerts = newAlerts,
policy = account.pushPolicy ?: "all",
)
subLog.i(R.string.push_subscription_updated)
} else {
subLog.i(R.string.push_subscription_creating)
val keyPair = provider.generateKeyPair()
val auth = ByteArray(16).also { SecureRandom().nextBytes(it) }
val p256dh = encodeP256Dh(keyPair.public as ECPublicKey)
val response = api.createPushSubscription(
a = account,
endpointUrl = newUrl,
p256dh = p256dh.encodeBase64Url(),
auth = auth.encodeBase64Url(),
alerts = newAlerts,
policy = account.pushPolicy ?: "all",
)
val serverKeyStr = response.string("server_key")
?: error("missing server_key.")
val serverKey = serverKeyStr.decodeBase64()
// p256dhは65バイトのはず
// authは16バイトのはず
// serverKeyは65バイトのはず
// 登録できたらアカウントに覚える
daoStatus.savePushKey(
acct = account.acct,
pushKeyPrivate = keyPair.private.encoded,
pushKeyPublic = p256dh,
pushAuthSecret = auth,
pushServerKey = serverKey,
lastPushEndpoint = newUrl,
)
subLog.i(R.string.push_subscription_completed)
}
}
private suspend fun isSameAlerts(
subLog: SubscriptionLogger,
account: SavedAccount,
oldSubscriptionJson: JsonObject?,
newAlerts: JsonObject,
): Boolean {
oldSubscriptionJson ?: return false
val oldSubscription = TootPushSubscription(oldSubscriptionJson)
// STがstatus通知に対応した時期に古いサーバでここを通ると
// flagsの値が変わりendpoint URLも変わった状態で購読を自動更新してしまう
// しかしそのタイミングではサーバは古いのでサーバ側の購読内容は変化しなかった。
// サーバ上の購読アラートのリスト
var alertsOld = oldSubscription.alerts.entries
.mapNotNull { if (it.value) it.key else null }
.sorted()
// 期待する購読アラートのリスト
var alertsNew = newAlerts.entries
.mapNotNull { pair -> pair.key.takeIf { pair.value == true } }
.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)
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()}")
subLog.i("notification type set changed.")
false
}
}
private fun SavedAccount.alerts() = JsonObject().apply {
// 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)
// 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)
}
// サーバが知らないアラート種別は比較対象から除去する
private fun Iterable<String>.knownOnly(account: SavedAccount, ti: TootInstance) = filter {
when (it) {
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_FAVOURITE -> true
TootNotification.TYPE_FOLLOW -> true
TootNotification.TYPE_FOLLOW_REQUEST -> ti.versionGE(TootInstance.VERSION_3_1_0_rc1)
TootNotification.TYPE_MENTION -> true
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)
//////////////////////
// Fedibird拡張
TootNotification.TYPE_EMOJI_REACTION,
TootNotification.TYPE_EMOJI_REACTION_PLEROMA,
-> InstanceCapability.emojiReaction(account, ti)
TootNotification.TYPE_SCHEDULED_STATUS,
-> InstanceCapability.scheduledStatus(account, ti)
TootNotification.TYPE_STATUS_REFERENCE,
-> InstanceCapability.statusReference(account, ti)
else -> {
log.w("${account.acct}: unknown alert '$it'. server version='${ti.version}'")
false // 未知のアラートの差異は比較しない。でないと購読を何度も繰り返すことになる
}
}
}
override suspend fun formatPushMessage(
a: SavedAccount,
pm: PushMessage,
) {
val json = pm.messageJson ?: error("missing messageJson")
pm.notificationType = json.string("notification_type")
pm.iconLarge = a.supplyBaseUrl(json.string("icon"))
pm.text = arrayOf(
// あなたのトゥートが tateisu 🤹 さんにお気に入り登録されました
json.string("title"),
).mapNotNull { it?.trim()?.notBlank() }
.joinToString("\n")
.ellipsizeDot3(128)
pm.textExpand = arrayOf(
// あなたのトゥートが tateisu 🤹 さんにお気に入り登録されました
json.string("title"),
// 対象の投稿の本文?
json.string("body"),
// 対象の投稿の本文? (古い
json.jsonObject("data")?.string("content"),
).mapNotNull { it?.trim()?.notBlank() }
.joinToString("\n")
.ellipsizeDot3(400)
when {
pm.notificationType.isNullOrEmpty() -> {
// old mastodon
// {
// "title": "あなたのトゥートが tateisu 🤹 さんにお気に入り登録されました",
// "image": null,
// "badge": "https://mastodon2.juggler.jp/badge.png",
// "tag": 84,
// "timestamp": "2018-05-11T17:06:42.887Z",
// "icon": "/system/accounts/avatars/000/000/003/original/72f1da33539be11e.jpg",
// "data": {
// "content": ":enemy_bullet:",
// "nsfw": null,
// "url": "https://mastodon2.juggler.jp/web/statuses/98793123081777841",
// "actions": [],
// "access_token": null,
// "message": "%{count} 件の通知",
// "dir": "ltr"
// }
// }
json.string("timestamp")?.parseTimeIso8601()
?.let { pm.timestamp = it }
// 重複排除は完全に諦める
pm.notificationId = pm.timestamp.toString()
pm.iconSmall = a.supplyBaseUrl(json.string("badge"))
}
else -> {
// Mastodon 4.0
// {
// "access_token": "***",
// "preferred_locale": "ja",
// "notification_id": 341897,
// "notification_type": "favourite",
// "icon": "https://m1j.zzz.ac/aed1...e5343f2e7b.png",
// "title": "tateisu⛏@テスト鯖 :ct080:さんにお気に入りに登録されました",
// "body": "テスト"
// }
pm.notificationId = json.string("notification_id")
// - iconSmall は通知タイプに合わせてアプリが用意するらしい
// - タイムスタンプ情報はない。
}
}
// 通知のミュートについて:
// - アプリ名がないのでアプリ名ミュートは使えない
// - notification.user のfull acct がないのでふぁぼ魔ミュートは行えない
// - テキスト本文のミュートは…部分的には可能
if(pm.textExpand?.let{TootStatus.muted_word?.matchShort(it)}==true){
error("muted by text word.")
}
// // ふぁぼ魔ミュート
// when ( pm.notificationType) {
// TootNotification.TYPE_REBLOG,
// TootNotification.TYPE_FAVOURITE,
// TootNotification.TYPE_FOLLOW,
// TootNotification.TYPE_FOLLOW_REQUEST,
// TootNotification.TYPE_FOLLOW_REQUEST_MISSKEY,
// -> {
// val whoAcct = a.getFullAcct(user)
// if (TootStatus.favMuteSet?.contains(whoAcct) == true) {
// error("muted by favMuteSet ${whoAcct.pretty}")
// }
// }
// }
}
}