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

759 lines
29 KiB
Kotlin
Raw Normal View History

package jp.juggler.subwaytooter.push
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import androidx.work.WorkManager
import androidx.work.await
import jp.juggler.crypt.*
import jp.juggler.subwaytooter.ActCallback
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.api.push.ApiPushAppServer
import jp.juggler.subwaytooter.api.push.ApiPushMastodon
import jp.juggler.subwaytooter.api.push.ApiPushMisskey
import jp.juggler.subwaytooter.dialog.SuspendProgress
import jp.juggler.subwaytooter.notification.NotificationChannels
import jp.juggler.subwaytooter.notification.NotificationDeleteReceiver.Companion.intentNotificationDelete
import jp.juggler.subwaytooter.pref.PrefDevice
import jp.juggler.subwaytooter.pref.prefDevice
import jp.juggler.subwaytooter.push.*
import jp.juggler.subwaytooter.push.PushWorker.Companion.enqueuePushMessage
import jp.juggler.subwaytooter.push.PushWorker.Companion.enqueueRegisterEndpoint
import jp.juggler.subwaytooter.table.*
import jp.juggler.subwaytooter.util.loadIcon
import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.coroutine.EmptyScope
import jp.juggler.util.data.*
import jp.juggler.util.data.Base128.decodeBase128
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.withCaption
import jp.juggler.util.os.applicationContextSafe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
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
private val log = LogCategory("PushRepo")
private val defaultOkHttp by lazy {
OkHttpClient.Builder().apply {
connectTimeout(60, TimeUnit.SECONDS)
writeTimeout(60, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS)
}.build()
}
val Context.pushRepo: PushRepo
get() {
val okHttp = defaultOkHttp
val appDatabase = appDatabase
return PushRepo(
context = applicationContextSafe,
apiAppServer = ApiPushAppServer(okHttp),
apiMastodon = ApiPushMastodon(okHttp),
apiMisskey = ApiPushMisskey(okHttp),
daoSavedAccount = SavedAccount.Access(appDatabase, this),
daoPushMessage = PushMessage.Access(appDatabase),
daoStatus = AccountNotificationStatus.Access(appDatabase),
provider = defaultSecurityProvider,
prefDevice = prefDevice,
fcmHandler = fcmHandler,
)
}
class PushRepo(
private val context: Context,
private val apiMastodon: ApiPushMastodon,
private val apiMisskey: ApiPushMisskey,
private val apiAppServer: ApiPushAppServer,
private val daoSavedAccount: SavedAccount.Access,
private val daoPushMessage: PushMessage.Access,
private val daoStatus: AccountNotificationStatus.Access,
private val provider: Provider,
private val prefDevice: PrefDevice,
private val fcmHandler: FcmHandler,
) {
companion object {
@Suppress("RegExpSimplifiable")
private val reTailDigits = """([0-9]+)\z""".toRegex()
private val ncPushMessage = NotificationChannels.PushMessage
var refReporter: WeakReference<SuspendProgress.Reporter>? = null
}
private val pushMisskey by lazy {
PushMisskey(
context = context,
api = apiMisskey,
provider = provider,
prefDevice = prefDevice,
daoStatus = daoStatus,
)
}
private val pushMastodon by lazy {
PushMastodon(
2023-02-06 10:56:35 +01:00
context = context,
api = apiMastodon,
provider = provider,
prefDevice = prefDevice,
daoStatus = daoStatus,
)
}
/**
* UPでプッシュサービスを選ぶと呼ばれる
*/
suspend fun switchDistributor(
pushDistributor: String,
reporter: SuspendProgress.Reporter,
) {
val timeSwitchStart = System.currentTimeMillis()
refReporter = WeakReference(reporter)
log.i("switchDistributor: pushDistributor=$pushDistributor")
prefDevice.pushDistributor = pushDistributor
withContext(Dispatchers.IO) {
reporter.setMessage(context.getString(R.string.removing_old_distributer))
// WorkManagerの完了済みのジョブを捨てる
WorkManager.getInstance(context).pruneWork().await()
// Unified購読の削除
// 後でブロードキャストを受け取るかもしれない
UnifiedPush.unregisterApp(context)
// FCMトークンの削除。これでこの端末のこのアプリへの古いエンドポイント登録はgoneになり消えるはず
fcmHandler.deleteFcmToken()
when (pushDistributor) {
PrefDevice.PUSH_DISTRIBUTOR_NONE -> {
// 購読解除
reporter.setMessage("SubscriptionUpdateService.launch")
enqueueRegisterEndpoint(context)
}
PrefDevice.PUSH_DISTRIBUTOR_FCM -> {
// 特にイベントは来ないので、プッシュ購読をやりなおす
reporter.setMessage("SubscriptionUpdateService.launch")
enqueueRegisterEndpoint(context)
}
else -> {
reporter.setMessage("UnifiedPush.saveDistributor")
UnifiedPush.saveDistributor(context, pushDistributor)
// 何らかの理由で登録は壊れることがあるため、登録し直す
reporter.setMessage("UnifiedPush.registerApp")
UnifiedPush.registerApp(context)
// 少し後にonNewEndpointが発生するので、続きはそこで
}
}
}
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)
break
}
delay(1000L)
}
}
/**
* switchDistributor UnifiedPush.registerAppする
*
* UpMessageReceiver onNewEndpoint が呼ばれる
*
* PushWorker ACTION_UP_ENDPOINT が登録される
*
* ワーカーからnewUpEndpoint()が呼ばれる
*/
suspend fun newUpEndpoint(upEndpoint: String) {
refReporter?.get()?.setMessage("新しい UnifiedPush endpoint URL を取得しました")
val upPackageName = UnifiedPush.getDistributor(context).notEmpty()
?: error("missing upPackageName")
if (upPackageName != prefDevice.pushDistributor) {
log.w("newEndpoint: race condition detected!")
}
// 古いエンドポイントを別プロパティに覚えておく
prefDevice.upEndpoint
?.takeIf { it.isNotEmpty() && it != upEndpoint }
?.let { prefDevice.upEndpointExpired = it }
prefDevice.upEndpoint = upEndpoint
// 購読の更新
registerEndpoint(keepAliveMode = false)
}
/**
* - PushWorkerのACTION_UP_ENDPOINTの実行中に呼ばれる
* - PushWorkerのACTION_REGISTER_ENDPOINTの実行中に呼ばれる
*/
suspend fun registerEndpoint(
keepAliveMode: Boolean,
) {
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
}
} 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
}
} catch (ex: Throwable) {
log.w(ex, "can't forgot upEndpointExpired")
}
val realAccounts = daoSavedAccount.loadRealAccounts()
.filter { !it.isPseudo && it.isConfirmed }
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.")
}
}
var willRemoveSubscription = false
// アプリサーバにendpointを登録する
refReporter?.get()?.setMessage("アプリサーバにプッシュサービスの情報を送信しています")
2023-02-06 10:56:35 +01:00
if (!fcmHandler.hasFcm && prefDevice.pushDistributor == PrefDevice.PUSH_DISTRIBUTOR_FCM) {
2023-02-06 10:56:35 +01:00
log.w("fmc selected, but this is noFcm build. unset distributer.")
prefDevice.pushDistributor = null
}
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.i("registerEndpoint dist=${prefDevice.pushDistributor}, acctHashList=${acctHashList.size}")
registerEndpointUnifiedPush(acctHashList)
}
}
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)
}
log.i("appServerHash updated. saveCount=$saveCount")
}
}
2023-02-06 10:56:35 +01:00
realAccounts.forEach { account ->
val subLog = object : PushBase.SubscriptionLogger {
override val context = this@PushRepo.context
override fun i(msg: String) {
2023-02-06 10:56:35 +01:00
log.i("[${account.acct}]$msg")
}
override fun e(msg: String) {
2023-02-06 10:56:35 +01:00
log.e("[${account.acct}]$msg")
daoAccountNotificationStatus.updateSubscriptionError(
2023-02-06 10:56:35 +01:00
account.acct,
msg
)
}
override fun e(ex: Throwable, msg: String) {
2023-02-06 10:56:35 +01:00
log.e(ex, "[${account.acct}]$msg")
daoAccountNotificationStatus.updateSubscriptionError(
2023-02-06 10:56:35 +01:00
account.acct,
ex.withCaption(msg)
)
}
}
try {
2023-02-06 10:56:35 +01:00
refReporter?.get()?.setMessage("${account.acct.pretty} のWebPush購読を更新しています")
daoAccountNotificationStatus.updateSubscriptionError(
2023-02-06 10:56:35 +01:00
account.acct,
null
)
2023-02-06 10:56:35 +01:00
pushBase(account).updateSubscription(
subLog = subLog,
2023-02-06 10:56:35 +01:00
account = account,
willRemoveSubscription = willRemoveSubscription || !account.isRequiredPushSubscription(),
forceUpdate = false,
)
} catch (ex: Throwable) {
subLog.e(ex, "updateSubscription failed.")
}
}
prefDevice.timeLastEndpointRegister = System.currentTimeMillis()
}
private suspend fun registerEndpointUnifiedPush(acctHashList: List<String>) =
when (val upEndpoint = prefDevice.upEndpoint) {
null, "" -> {
log.w("missing upEndpoint. can't register endpoint.")
null
}
else -> {
apiAppServer.endpointUpsert(
upUrl = upEndpoint,
fcmToken = null,
acctHashList = acctHashList
)
}
}
private suspend fun registerEndpointFcm(acctHashList: List<String>) =
when (val fcmToken = fcmHandler.loadFcmToken()) {
null, "" -> {
log.w("missing fcmToken. can't register endpoint.")
null
}
else -> {
apiAppServer.endpointUpsert(
upUrl = null,
fcmToken = fcmToken,
acctHashList = acctHashList
)
}
}
/**
* アカウント設定からSNSサーバに購読を行う
*
* willRemoveSubscription=trueの場合購読を削除する
* アクセストークン更新やアカウント削除の際に古い購読を捨てたい場合に使う
*/
suspend fun updateSubscription(
subLog: PushBase.SubscriptionLogger,
2023-02-06 10:56:35 +01:00
account: SavedAccount,
willRemoveSubscription: Boolean,
forceUpdate: Boolean = false,
) {
2023-02-06 10:56:35 +01:00
pushBase(account).updateSubscription(
subLog = subLog,
2023-02-06 10:56:35 +01:00
account = account,
willRemoveSubscription = willRemoveSubscription || !account.isRequiredPushSubscription(),
forceUpdate = forceUpdate,
)
}
2023-02-06 10:56:35 +01:00
private fun pushBase(account: SavedAccount) = when {
account.isMisskey -> pushMisskey
else -> pushMastodon
}
//////////////////////////////////////////////////////////////////////////////
// メッセージの処理
/**
* FcmHandlerから呼ばれる
*/
fun handleFcmMessage(data: Map<String, String>) {
data["d"]?.decodeBase128()?.let { saveRawMessage(it) }
}
/**
* UpMessageReceiverから呼ばれる
*/
fun saveUpMessage(message: ByteArray) {
saveRawMessage(message)
}
/**
* 受信した生データを保存して後はワーカーに任せる
*/
private fun saveRawMessage(bytes: ByteArray) {
val pm = PushMessage(rawBody = bytes)
daoPushMessage.save(pm)
enqueuePushMessage(context, pm.id)
}
/**
* UIで再解読を選択した
*
* - 実際のアプリでは解読できたものだけを保存したいがこれは試験アプリなので
*/
suspend fun reprocess(pm: PushMessage) {
withContext(AppDispatchers.IO) {
updateMessage(pm.id, allowDupilicateNotification = true)
}
}
/**
* UpWorkerから呼ばれる
* 保存データを解釈して通知を出す
*/
suspend fun updateMessage(
messageId: Long,
allowDupilicateNotification: Boolean = false,
) {
// DBからロード
val pm = daoPushMessage.find(messageId)
?: error("missing pushMessage")
try {
// rawBodyをBinPackMapにデコード
var map = pm.rawBody?.decodeBinPackMap()
?: error("binPack decode failed.")
// ペイロードがなくてURLが付与されたメッセージは
// アプリサーバから読み直す
if (map["b"] == null) {
map.string("l")?.let { largeObjectId ->
apiAppServer.getLargeObject(largeObjectId)
?.let {
map = it.decodeBinPack() as? BinPackMap
?: error("binPack decode failed.")
pm.rawBody = it
daoPushMessage.save(pm)
}
}
}
// acctHashがある
val acctHash = map.string("a") ?: error("missing a.")
val status = daoStatus.findByAcctHash(acctHash)
?: error("missing status for acctHash $acctHash")
val acct = status.acct.takeIf { it.isValidFull }
?: error("empty acct.")
pm.loginAcct = status.acct
val account = daoSavedAccount.loadAccountByAcct(acct)
?: error("missing account for acct ${status.acct}")
decodeMessageContent(status, pm, map)
val messageJson = pm.messageJson
if (messageJson == null) {
// デコード失敗
// 古い鍵で行った購読だろう。
// メッセージに含まれるappServerHashを指定してendpoint登録を削除する
// するとアプリサーバはSNSサーバに対してgoneを返すようになり掃除が適切に行われるはず
map.string("c").notEmpty()?.let {
val count = apiAppServer.endpointRemove(hashId = it).int("count")
log.w("endpointRemove $count hashId=$it")
}
error("can't decode WebPush message to JSON.")
}
// Mastodonはなぜかアクセストークンが書いてあるので危険…
val messageJsonFiltered = messageJson.toString()
.replace(
""""access_token":"[^"]+"""".toRegex(),
""""access_token":"***""""
)
log.i("${status.acct} $messageJsonFiltered")
// ミュート用データを時々読む
TootStatus.updateMuteData()
// messageJsonを解釈して通知に出す内容を決める
pushBase(account).formatPushMessage(account, pm)
val notificationId = pm.notificationId
if (notificationId.isNullOrEmpty()) {
log.w("can't show notification. missing notificationId.")
return
}
if (!account.canNotificationShowing(pm.notificationType)) {
log.w("notificationType ${pm.notificationType} is disabled.")
return
}
if (!allowDupilicateNotification &&
daoNotificationShown.duplicateOrPut(acct, notificationId)
) {
log.w("can't show notification. it's duplicate. $acct $notificationId")
return
}
showPushNotification(pm, account, notificationId)
} catch (ex: Throwable) {
log.e(ex, "updateMessage failed.")
pm.formatError = ex.withCaption()
} finally {
daoPushMessage.save(pm)
}
}
/**
* プッシュされたデータを解読してDB上の項目を更新する
*
* - 実際のアプリでは解読できたものだけを保存したいがこれは試験アプリなので
*/
private fun decodeMessageContent(
status: AccountNotificationStatus,
pm: PushMessage,
map: BinPackMap,
) {
val encryptedBody = map.bytes("b") ?: error("missing encryptedBody")
val headers = map.map("h") ?: error("missing headers")
pm.headerJson = buildJsonObject {
headers.entries.forEach { e ->
put(e.key.toString(), e.value.toString())
}
}
// ヘッダを探すときは小文字化
fun header(name: String): String? = headers.string(name.lowercase())
// log.i("headerJson.keys=${headerJson.keys.joinToString(",")}")
// headerJson={
// "Digest":"SHA-256=nnn",
// "Content-Encoding":"aesgcm",
// "Encryption":"salt=75n4Si2vAVv2xZFXnIh5Ww",
// "Crypto-Key":"dh=XXX;p256ecdsa=XXX",
// "Authorization":"WebPush XXX.XXX.XXX"
// }
try {
if (header("Content-Encoding")?.trim() == "aes128gcm") {
Aes128GcmDecoder(encryptedBody.byteRangeReader(), provider).run {
deriveKeyWebPush(
// receiver private key in X509 format
receiverPrivateBytes = status.pushKeyPrivate
?: error("missing pushKeyPrivate"),
// receiver public key in 65bytes X9.62 uncompressed format
receiverPublicBytes = status.pushKeyPublic
?: error("missing pushKeyPublic"),
// auth secrets created at subscription
authSecret = status.pushAuthSecret ?: error("missing pushAuthSecret"),
)
decode()
}
} else {
// Crypt-Key から dh と p256ecdsa を見る
val cryptKeys = header("Crypto-Key")
?.parseSemicolon() ?: error("missing Crypto-Key")
AesGcmDecoder(
receiverPrivateBytes = status.pushKeyPrivate ?: error("missing pushKeyPrivate"),
receiverPublicBytes = status.pushKeyPublic ?: error("missing pushKeyPublic"),
senderPublicBytes = cryptKeys["dh"]?.decodeBase64()
?: status.pushServerKey ?: error("missing pushServerKey"),
authSecret = status.pushAuthSecret ?: error("missing pushAuthSecret"),
saltBytes = header("Encryption")?.parseSemicolon()
?.get("salt")?.decodeBase64()
?: error("missing Encryption.salt"),
provider = provider
).run {
deriveKey()
decode(encryptedBody.toByteRange())
}
}
} catch (ex: Throwable) {
// クライアント側の鍵が異なる等でデコードできない場合がある
log.e(ex.withCaption("message decipher failed."))
null
}?.decodeUTF8()?.decodeJsonObject()?.let {
pm.messageJson = it
daoPushMessage.save(pm)
}
}
/**
* SNSからの通知を表示する
*/
private suspend fun showPushNotification(
pm: PushMessage,
account: SavedAccount,
notificationId: String,
) {
if (ncPushMessage.isDisabled(context)) {
log.w("ncPushMessage isDisabled.")
return
}
val density = context.resources.displayMetrics.density
val iconAndColor = pm.iconColor()
suspend fun PushMessage.loadSmallIcon(context: Context): IconCompat {
iconSmall?.notEmpty()
?.let { context.loadIcon(pm.iconSmall, (24f * density + 0.5f).toInt()) }
?.let { return IconCompat.createWithBitmap(it) }
val iconId = iconAndColor.iconId
return IconCompat.createWithResource(context, iconId)
}
val iconSmall = pm.loadSmallIcon(context)
val iconBitmapLarge = context.loadIcon(pm.iconLarge, (48f * density + 0.5f).toInt())
val params = listOf(
"db_id" to account.db_id.toString(),
// URIをユニークにするため。参照されない
"type" to "v2push", // "type" to trackingType.str,
// URIをユニークにするため。参照されない
"notificationId" to notificationId,
).joinToString("&") {
"${it.first.encodePercent()}=${it.second.encodePercent()}"
}
val iTap = Intent(context, ActCallback::class.java).apply {
data = "subwaytooter://notification_click/?$params".toUri()
// FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY を付与してはいけない
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val piTap = PendingIntent.getActivity(
context,
ncPushMessage.pircTap,
iTap,
PendingIntent.FLAG_IMMUTABLE
)
val urlDelete = "${ncPushMessage.uriPrefixDelete}/${pm.id}"
val iDelete = context.intentNotificationDelete(urlDelete.toUri())
val piDelete =
PendingIntent.getBroadcast(
context,
ncPushMessage.pircDelete,
iDelete,
PendingIntent.FLAG_IMMUTABLE
)
// val iTap = intentActMessage(pm.messageDbId)
// val piTap = PendingIntent.getActivity(this, nc.pircTap, iTap, PendingIntent.FLAG_IMMUTABLE)
ncPushMessage.notify(context, urlDelete) {
color = pm.iconColor().colorRes.notZero()
?.let { ContextCompat.getColor(context, it) }
?: account.notificationAccentColor.notZero()
?: ContextCompat.getColor(context, R.color.colorOsNotificationAccent)
setSmallIcon(iconSmall)
iconBitmapLarge?.let { setLargeIcon(it) }
setContentTitle(pm.loginAcct?.pretty)
setContentText(pm.text)
setWhen(pm.timestamp)
setContentIntent(piTap)
setDeleteIntent(piDelete)
setAutoCancel(true)
pm.textExpand.notEmpty()?.let {
setStyle(NotificationCompat.BigTextStyle().bigText(it))
}
setGroup(context.packageName + ":" + account.acct.ascii)
}
}
/**
* 通知を消す
*
* - 試験アプリなのであまり積極的に消さない
*/
fun deleteSnsNotification(messageDbId: Long) {
try {
ncPushMessage.cancel(context, "${ncPushMessage.uriPrefixDelete}/${messageDbId}")
} catch (ex: Throwable) {
log.e(ex, "deleteSnsNotification failed. messageDbId=$messageDbId")
}
}
/**
* 通知をスワイプして削除した
* - URLからDB上の項目のIDを取得
* - timeDismissを更新する
*/
fun onDeleteNotification(uri: String) {
val messageDbId = reTailDigits.find(uri)?.groupValues?.elementAtOrNull(0)
?.toLongOrNull()
?: error("missing messageDbId in $uri")
daoPushMessage.dismiss(messageDbId)
}
/**
* 通知タップのインテントをメイン画面が受け取った
*/
fun onTapNotification(account: SavedAccount) {
EmptyScope.launch(AppDispatchers.IO) {
try {
daoPushMessage.dismissByAcct(account.acct)
} catch (ex: Throwable) {
log.e(ex, "onTapNotification failed.")
}
}
}
}