310 lines
12 KiB
Kotlin
310 lines
12 KiB
Kotlin
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.TootParser
|
|
import jp.juggler.subwaytooter.api.entity.TootAccount.Companion.tootAccount
|
|
import jp.juggler.subwaytooter.api.entity.TootNotification
|
|
import jp.juggler.subwaytooter.api.entity.TootStatus
|
|
import jp.juggler.subwaytooter.api.entity.parseItem
|
|
import jp.juggler.subwaytooter.api.push.ApiPushMisskey
|
|
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 java.security.Provider
|
|
import java.security.SecureRandom
|
|
import java.security.interfaces.ECPublicKey
|
|
|
|
class PushMisskey(
|
|
private val context: Context,
|
|
private val api: ApiPushMisskey,
|
|
private val provider: Provider =
|
|
defaultSecurityProvider,
|
|
override val prefDevice: PrefDevice =
|
|
lazyContext.prefDevice,
|
|
override val daoStatus: AccountNotificationStatus.Access =
|
|
AccountNotificationStatus.Access(appDatabase),
|
|
) : PushBase() {
|
|
companion object {
|
|
private val log = LogCategory("PushMisskey")
|
|
}
|
|
|
|
override suspend fun updateSubscription(
|
|
subLog: SubscriptionLogger,
|
|
account: SavedAccount,
|
|
willRemoveSubscription: Boolean,
|
|
forceUpdate: Boolean,
|
|
) {
|
|
val newUrl = snsCallbackUrl(account)
|
|
|
|
val lastEndpointUrl = daoStatus.lastEndpointUrl(account.acct)
|
|
?: newUrl
|
|
|
|
var status = daoStatus.load(account.acct)
|
|
|
|
@Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE")
|
|
var hasEmptySubscription = false
|
|
|
|
if (!lastEndpointUrl.isNullOrEmpty()) {
|
|
val lastSubscription = when (lastEndpointUrl) {
|
|
null, "" -> null
|
|
else -> try {
|
|
// Misskeyは2022/12/18に現在の購読を確認するAPIができた
|
|
api.getPushSubscription(account, lastEndpointUrl)
|
|
// 購読がない => 空オブジェクト (v13 drdr.club でそんな感じ)
|
|
} catch (ex: Throwable) {
|
|
// APIがない => 404 (v10 めいすきーのソースと動作で確認)
|
|
when ((ex as? ApiError)?.response?.code) {
|
|
in 400 until 500 -> null
|
|
else -> throw ex
|
|
}
|
|
}
|
|
}
|
|
|
|
if (lastSubscription != null) {
|
|
if (lastSubscription.size == 0) {
|
|
// 購読がないと空レスポンスになり、アプリ側で空オブジェクトに変換される
|
|
@Suppress("UNUSED_VALUE")
|
|
hasEmptySubscription = true
|
|
} else if (lastEndpointUrl == newUrl && !willRemoveSubscription && !forceUpdate) {
|
|
when (lastSubscription.boolean("sendReadMessage")) {
|
|
false -> subLog.i(R.string.push_subscription_keep_using)
|
|
else -> {
|
|
// 未読クリア通知はオフにしたい
|
|
api.updatePushSubscription(account, newUrl, sendReadMessage = false)
|
|
subLog.i(R.string.push_subscription_off_unread_notification)
|
|
}
|
|
}
|
|
return
|
|
} else {
|
|
// 古い購読はあったが、削除したい
|
|
api.deletePushSubscription(account, lastEndpointUrl)
|
|
daoStatus.deleteLastEndpointUrl(account.acct)
|
|
if (willRemoveSubscription) {
|
|
subLog.i(R.string.push_subscription_delete_current)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
return
|
|
} else if (willRemoveSubscription) {
|
|
// 購読を解除したい。
|
|
// hasEmptySubscription が真なら購読はないが、
|
|
// とりあえず何か届いても確実に読めないようにする
|
|
when (status?.pushKeyPrivate) {
|
|
null -> subLog.i(R.string.push_subscription_is_not_required)
|
|
else -> {
|
|
daoStatus.deletePushKey(account.acct)
|
|
subLog.i(R.string.push_subscription_is_not_required_delete_secret_keys)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// 鍵がなければ作る
|
|
if (status?.pushKeyPrivate == null ||
|
|
status.pushKeyPublic == null ||
|
|
status.pushAuthSecret == null
|
|
) {
|
|
subLog.i(R.string.push_subscription_creating_new_secret_key)
|
|
val keyPair = provider.generateKeyPair()
|
|
val auth = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
|
val p256dh = encodeP256Dh(keyPair.public as ECPublicKey)
|
|
daoStatus.savePushKey(
|
|
account.acct,
|
|
pushKeyPrivate = keyPair.private.encoded,
|
|
pushKeyPublic = p256dh,
|
|
pushAuthSecret = auth,
|
|
)
|
|
status = daoStatus.load(account.acct)
|
|
}
|
|
|
|
// 購読する
|
|
subLog.i(R.string.push_subscription_creating)
|
|
status!!
|
|
val json = api.createPushSubscription(
|
|
a = account,
|
|
endpoint = newUrl,
|
|
auth = status.pushAuthSecret!!.encodeBase64Url(),
|
|
publicKey = status.pushKeyPublic!!.encodeBase64Url(),
|
|
sendReadMessage = false,
|
|
)
|
|
// https://github.com/syuilo/misskey/issues/2541
|
|
// https://github.com/syuilo/misskey/commit/4c6fb60dd25d7e2865fc7c4d97728593ffc3c902
|
|
// 2018/9/1 の上記コミット以降、Misskeyでもサーバ公開鍵を得られるようになった
|
|
val serverKey = json.string("key")
|
|
?.notEmpty()?.decodeBase64()
|
|
?: error("missing server key in response of sw/register API.")
|
|
if (!serverKey.contentEquals(status.pushServerKey)) {
|
|
daoStatus.saveServerKey(
|
|
acct = account.acct,
|
|
lastPushEndpoint = newUrl,
|
|
pushServerKey = serverKey,
|
|
)
|
|
subLog.i(R.string.push_subscription_server_key_saved)
|
|
}
|
|
subLog.i(R.string.push_subscription_completed)
|
|
}
|
|
|
|
/*
|
|
https://github.com/syuilo/misskey/blob/master/src/services/create-notification.ts#L46
|
|
Misskeyは通知に既読の概念があり、イベント発生後2秒たっても未読の時だけプッシュ通知が発生する。
|
|
WebUIを開いていると通知はすぐ既読になるのでプッシュ通知は発生しない。
|
|
プッシュ通知のテスト時はST2台を使い、片方をプッシュ通知の受信チェック、もう片方を投稿などの作業に使うことになる。
|
|
*/
|
|
override suspend fun formatPushMessage(
|
|
a: SavedAccount,
|
|
pm: PushMessage,
|
|
) {
|
|
val json = pm.messageJson ?: error("missign messageJson")
|
|
|
|
when (val eventType = json.string("type")) {
|
|
"notification" -> {
|
|
val body = json.jsonObject("body")
|
|
?: error("missing body of notification")
|
|
val parser = TootParser(context, a)
|
|
|
|
val whoJson = body.jsonObject("user")
|
|
var who = parseItem(whoJson) { tootAccount(parser, it) }
|
|
|
|
body.jsonObject("note")?.let { noteJson ->
|
|
if (noteJson["user"] == null) {
|
|
noteJson["user"] = when (noteJson.string("userId")) {
|
|
null, "" -> null
|
|
who?.id?.toString() -> whoJson
|
|
a.loginAccount?.id?.toString() -> a.loginAccount?.json
|
|
else -> null
|
|
}
|
|
}
|
|
}
|
|
|
|
val notification = parser.notification(body)
|
|
?: error("can't parse notification. json=$body")
|
|
|
|
who = notification.account
|
|
|
|
// アプリミュートと単語ミュート
|
|
if (notification.status?.checkMuted() == true) {
|
|
error("this message is muted by app or word.")
|
|
}
|
|
|
|
// ふぁぼ魔ミュート
|
|
when (notification.type) {
|
|
TootNotification.TYPE_REBLOG,
|
|
TootNotification.TYPE_FAVOURITE,
|
|
TootNotification.TYPE_FOLLOW,
|
|
TootNotification.TYPE_FOLLOW_REQUEST,
|
|
TootNotification.TYPE_FOLLOW_REQUEST_MISSKEY,
|
|
-> {
|
|
val whoAcct = a.getFullAcct(who)
|
|
if (TootStatus.favMuteSet?.contains(whoAcct) == true) {
|
|
error("muted by favMuteSet ${whoAcct.pretty}")
|
|
}
|
|
}
|
|
}
|
|
|
|
// バッジ画像のURLはない。通知種別により決まる
|
|
pm.iconSmall = null
|
|
pm.iconLarge = a.supplyBaseUrl(who?.avatar_static)
|
|
pm.notificationType = notification.type
|
|
pm.notificationId = notification.id.toString()
|
|
|
|
json.long("dateTime")?.let { pm.timestamp = it }
|
|
|
|
pm.text = arrayOf(
|
|
notification.getNotificationLine(context),
|
|
).mapNotNull { it.trim().notBlank() }
|
|
.joinToString("\n")
|
|
.ellipsizeDot3(128)
|
|
|
|
pm.textExpand = arrayOf(
|
|
pm.text,
|
|
notification.status?.decoded_content,
|
|
).mapNotNull { it?.trim()?.notBlank() }
|
|
.joinToString("\n")
|
|
.ellipsizeDot3(400)
|
|
}
|
|
|
|
// 通知以外のイベントは全部無視したい
|
|
else -> error("謎のイベント $eventType json=$json")
|
|
}
|
|
}
|
|
}
|
|
/*
|
|
|
|
Misskey13
|
|
{
|
|
"type": "notification",
|
|
"body": {
|
|
"id": "9ayflq5wj4",
|
|
"createdAt": "2023-02-07T23:22:38.132Z",
|
|
"type": "reaction",
|
|
"isRead": false,
|
|
"userId": "80jbzppr37",
|
|
"user": {
|
|
"id": "80jbzppr37",
|
|
"name": "tateisu🔧",
|
|
"username": "tateisu",
|
|
"host": "fedibird.com",
|
|
"avatarUrl": "https://nos3.arkjp.net/avatar.webp?url=https%3A%2F%2Fs3.fedibird.com%2Faccounts%2Favatars%2F000%2F010%2F223%2Foriginal%2Fb7ace6ef7eaaf49f.png&avatar=1",
|
|
"avatarBlurhash": "yMMHS-t71NWX~qx]%2yEf6i_kCoKn%M{tSkCoJaeM{ayoeyEWBxtt7IAWBWqShkCi_WBt7jZRkMxayt6aeWray%Mxvj[oeofM|WBRj",
|
|
"isBot": false,
|
|
"isCat": false,
|
|
"instance": {
|
|
"name": "Fedibird",
|
|
"softwareName": "fedibird",
|
|
"softwareVersion": "0.1",
|
|
"iconUrl": "https://fedibird.com/android-chrome-192x192.png",
|
|
"faviconUrl": "https://fedibird.com/favicon.ico",
|
|
"themeColor": "#282c37"
|
|
},
|
|
"emojis": {},
|
|
"onlineStatus": "unknown"
|
|
},
|
|
"note": {
|
|
"id": "9aybef5b1d",
|
|
"createdAt": "2023-02-07T21:24:58.799Z",
|
|
"userId": "7rm6y6thc1",
|
|
"text": "(📎1)",
|
|
"visibility": "public",
|
|
"localOnly": false,
|
|
"renoteCount": 0,
|
|
"repliesCount": 0,
|
|
"reactions": {
|
|
"👍": 1,
|
|
":kakkoii@.:": 1,
|
|
":utsukushii@.:": 1
|
|
},
|
|
"reactionEmojis": {
|
|
"blobcatlobster_MUDAMUDAMUDA@fedibird.com": "https://nos3.arkjp.net/emoji.webp?url=https%3A%2F%2Fs3.fedibird.com%2Fcustom_emojis%2Fimages%2F000%2F151%2F856%2Foriginal%2F936dd0a34673cb19.png"
|
|
},
|
|
"fileIds": [
|
|
"9aybedosdl"
|
|
],
|
|
"files": [...],
|
|
],
|
|
"replyId": null,
|
|
"renoteId": null
|
|
},
|
|
"reaction": "👍"
|
|
},
|
|
"userId": "7rm6y6thc1",
|
|
"dateTime": 1675812160174
|
|
}
|
|
|
|
|
|
*/ |