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

759 lines
29 KiB
Kotlin

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(
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("アプリサーバにプッシュサービスの情報を送信しています")
if (!fcmHandler.hasFcm && prefDevice.pushDistributor == PrefDevice.PUSH_DISTRIBUTOR_FCM) {
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")
}
}
realAccounts.forEach { account ->
val subLog = object : PushBase.SubscriptionLogger {
override val context = this@PushRepo.context
override fun i(msg: String) {
log.i("[${account.acct}]$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 {
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.")
}
}
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,
account: SavedAccount,
willRemoveSubscription: Boolean,
forceUpdate: Boolean = false,
) {
pushBase(account).updateSubscription(
subLog = subLog,
account = account,
willRemoveSubscription = willRemoveSubscription || !account.isRequiredPushSubscription(),
forceUpdate = forceUpdate,
)
}
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.")
}
}
}
}