496 lines
20 KiB
Kotlin
496 lines
20 KiB
Kotlin
package jp.juggler.subwaytooter.notification
|
|
|
|
import android.content.Context
|
|
import androidx.core.app.NotificationCompat
|
|
import jp.juggler.subwaytooter.R
|
|
import jp.juggler.subwaytooter.api.TootApiCallback
|
|
import jp.juggler.subwaytooter.api.TootApiClient
|
|
import jp.juggler.subwaytooter.api.TootParser
|
|
import jp.juggler.subwaytooter.api.entity.EntityId
|
|
import jp.juggler.subwaytooter.api.entity.TootNotification
|
|
import jp.juggler.subwaytooter.api.entity.TootStatus
|
|
import jp.juggler.subwaytooter.notification.CheckerWakeLocks.Companion.checkerWakeLocks
|
|
import jp.juggler.subwaytooter.notification.PullNotification.getMessageNotifications
|
|
import jp.juggler.subwaytooter.notification.PullNotification.removeMessageNotification
|
|
import jp.juggler.subwaytooter.notification.PullNotification.showMessageNotification
|
|
import jp.juggler.subwaytooter.pref.PrefB
|
|
import jp.juggler.subwaytooter.table.*
|
|
import jp.juggler.util.coroutine.AppDispatchers
|
|
import jp.juggler.util.data.JsonObject
|
|
import jp.juggler.util.data.notEmpty
|
|
import jp.juggler.util.log.LogCategory
|
|
import kotlinx.coroutines.*
|
|
import kotlinx.coroutines.sync.Mutex
|
|
import kotlinx.coroutines.sync.withLock
|
|
import java.util.*
|
|
import java.util.concurrent.ConcurrentHashMap
|
|
import kotlin.coroutines.coroutineContext
|
|
import kotlin.math.min
|
|
|
|
/**
|
|
* 1アカウント,1回ごとの通知チェック処理
|
|
*/
|
|
class PollingChecker(
|
|
val context: Context,
|
|
private val accountDbId: Long,
|
|
private var injectData: List<TootNotification> = emptyList(),
|
|
) {
|
|
companion object {
|
|
val log = LogCategory("PollingChecker")
|
|
|
|
val commonMutex = Mutex()
|
|
|
|
private val mutexMap = ConcurrentHashMap<Long, Mutex>()
|
|
fun accountMutex(accountDbId: Long): Mutex = mutexMap.getOrPut(accountDbId) { Mutex() }
|
|
|
|
private val activeCheckers = LinkedList<PollingChecker>()
|
|
|
|
private fun addActiveChecker(c: PollingChecker) {
|
|
synchronized(activeCheckers) {
|
|
activeCheckers.add(c)
|
|
}
|
|
}
|
|
|
|
private fun removeActiveChecker(c: PollingChecker) {
|
|
synchronized(activeCheckers) {
|
|
activeCheckers.remove(c)
|
|
}
|
|
}
|
|
|
|
suspend fun cancelAllChecker() {
|
|
synchronized(activeCheckers) {
|
|
for (c in activeCheckers) {
|
|
try {
|
|
c.checkJob.cancel()
|
|
} catch (ex: Throwable) {
|
|
log.e(ex, "checkJob.cancel failed.")
|
|
}
|
|
}
|
|
}
|
|
while (true) {
|
|
val activeCount = synchronized(activeCheckers) {
|
|
activeCheckers.size
|
|
}
|
|
if (activeCount == 0) break
|
|
log.w("cancelAllChecker: waiting activeCheckers. $activeCount")
|
|
delay(333L)
|
|
}
|
|
}
|
|
}
|
|
|
|
private val wakelocks = checkerWakeLocks(context)
|
|
|
|
private lateinit var cache: NotificationCache
|
|
|
|
private val client = TootApiClient(
|
|
context,
|
|
callback = object : TootApiCallback {
|
|
override suspend fun isApiCancelled(): Boolean {
|
|
if (!coroutineContext.isActive) {
|
|
log.w("coroutineContext is not active!")
|
|
}
|
|
return !coroutineContext.isActive
|
|
}
|
|
}
|
|
)
|
|
|
|
private val checkJob = Job()
|
|
|
|
private fun createPolicyFilter(
|
|
account: SavedAccount,
|
|
): (TootNotification) -> Boolean = when (account.pushPolicy) {
|
|
|
|
"followed" -> { it ->
|
|
val who = it.account
|
|
when {
|
|
who == null -> true
|
|
account.isMe(who) -> true
|
|
|
|
else -> daoUserRelation.load(account.db_id, who.id).following
|
|
}
|
|
}
|
|
|
|
"follower" -> { it ->
|
|
val who = it.account
|
|
when {
|
|
it.type == TootNotification.TYPE_FOLLOW ||
|
|
it.type == TootNotification.TYPE_FOLLOW_REQUEST -> true
|
|
|
|
who == null -> true
|
|
account.isMe(who) -> true
|
|
|
|
else -> daoUserRelation.load(account.db_id, who.id).followed_by
|
|
}
|
|
}
|
|
|
|
"none" -> { _ -> false }
|
|
|
|
else -> { _ -> true }
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param onlyEnqueue Workerの定期実行ON/OFFの更新だけを行う
|
|
*/
|
|
suspend fun check(
|
|
checkNetwork: Boolean = true,
|
|
onlyEnqueue: Boolean = false,
|
|
progress: suspend (SavedAccount, PollingState) -> Unit,
|
|
) {
|
|
try {
|
|
addActiveChecker(this@PollingChecker)
|
|
|
|
// double check
|
|
if (importProtector.get()) {
|
|
log.w("aborted by importProtector.")
|
|
return
|
|
}
|
|
|
|
withContext(AppDispatchers.DEFAULT + checkJob) {
|
|
if (importProtector.get()) {
|
|
log.w("aborted by importProtector.")
|
|
return@withContext
|
|
}
|
|
|
|
val account = daoSavedAccount.loadAccount(accountDbId)
|
|
if (account == null || account.isPseudo || !account.isConfirmed) {
|
|
// 疑似アカウントはチェック対象外
|
|
// 未確認アカウントはチェック対象外
|
|
return@withContext
|
|
}
|
|
|
|
client.account = account
|
|
|
|
if (checkNetwork) {
|
|
progress(account, PollingState.CheckNetworkConnection)
|
|
wakelocks.checkConnection()
|
|
}
|
|
|
|
commonMutex.withLock {
|
|
// グローバル変数の暖気
|
|
TootStatus.updateMuteData()
|
|
}
|
|
|
|
// // installIdとデバイストークンの取得
|
|
// val deviceToken = loadFirebaseMessagingToken(context)
|
|
// loadInstallId(context, account, deviceToken, progress)
|
|
|
|
accountMutex(accountDbId).withLock {
|
|
if (!account.isRequiredPullCheck()) {
|
|
// 通知チェックの定期実行が不要なら
|
|
// 通知表示のエラーをクリアする
|
|
daoAccountNotificationStatus.updateNotificationError(
|
|
account.acct,
|
|
null
|
|
)
|
|
log.i("notification check not required.")
|
|
return@withLock
|
|
}
|
|
|
|
progress(account, PollingState.CheckNotifications)
|
|
|
|
PollingWorker2.enqueuePolling(context)
|
|
if (onlyEnqueue) {
|
|
// 定期実行状態の更新だけを行うフラグ
|
|
log.i("exit due to onlyEnqueue")
|
|
return@withLock
|
|
}
|
|
|
|
injectData.notEmpty()?.let { list ->
|
|
log.d("processInjectedData ${account.acct} size=${list.size}")
|
|
val nc = NotificationCache(accountDbId)
|
|
daoNotificationCache.loadInto(nc)
|
|
daoNotificationCache.inject(nc, account, list)
|
|
}
|
|
|
|
cache = NotificationCache(account.db_id).apply {
|
|
daoNotificationCache.loadInto(this)
|
|
requestAsync(
|
|
daoNotificationCache,
|
|
client,
|
|
account,
|
|
account.notificationFlags(),
|
|
) { result ->
|
|
// 通知取得のエラーを保存する
|
|
daoAccountNotificationStatus.updateNotificationError(
|
|
account.acct,
|
|
"${result.error} ${result.requestInfo}".trim()
|
|
)
|
|
if (result.error?.contains("Timeout") == true &&
|
|
!account.dontShowTimeout
|
|
) {
|
|
progress(account, PollingState.Timeout)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (PrefB.bpSeparateReplyNotificationGroup.value) {
|
|
var tr = TrackingRunner(
|
|
account = account,
|
|
trackingType = TrackingType.NotReply,
|
|
trackingName = PullNotification.TRACKING_NAME_DEFAULT
|
|
)
|
|
tr.checkAccount()
|
|
yield()
|
|
tr.updateNotification()
|
|
//
|
|
tr = TrackingRunner(
|
|
account = account,
|
|
trackingType = TrackingType.Reply,
|
|
trackingName = PullNotification.TRACKING_NAME_REPLY
|
|
)
|
|
tr.checkAccount()
|
|
yield()
|
|
tr.updateNotification()
|
|
} else {
|
|
val tr = TrackingRunner(
|
|
account = account,
|
|
trackingType = TrackingType.All,
|
|
trackingName = PullNotification.TRACKING_NAME_DEFAULT
|
|
)
|
|
tr.checkAccount()
|
|
yield()
|
|
tr.updateNotification()
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
removeActiveChecker(this)
|
|
}
|
|
}
|
|
|
|
inner class TrackingRunner(
|
|
val account: SavedAccount,
|
|
var trackingType: TrackingType = TrackingType.All,
|
|
var trackingName: String = "",
|
|
) {
|
|
private val notificationManager = wakelocks.notificationManager
|
|
|
|
private lateinit var nr: NotificationTracking
|
|
private val duplicateCheck = HashSet<EntityId>()
|
|
private val dstListData = LinkedList<NotificationData>()
|
|
val policyFilter = createPolicyFilter(account)
|
|
|
|
private val parser = TootParser(context, account)
|
|
|
|
suspend fun checkAccount() {
|
|
|
|
fun JsonObject.isMention() =
|
|
when (NotificationCache.parseNotificationType(account, this)) {
|
|
TootNotification.TYPE_REPLY, TootNotification.TYPE_MENTION -> true
|
|
else -> false
|
|
}
|
|
|
|
val jsonList = when (trackingType) {
|
|
TrackingType.All -> cache.data
|
|
TrackingType.Reply -> cache.data.filter { it.isMention() }
|
|
TrackingType.NotReply -> cache.data.filter { !it.isMention() }
|
|
}
|
|
|
|
this.nr = daoNotificationTracking
|
|
.load(account.acct, account.db_id, trackingName)
|
|
|
|
// 新しい順に並んでいる。先頭から10件までを処理する。ただし処理順序は古い方から
|
|
val size = min(10, jsonList.size)
|
|
for (i in (0 until size).reversed()) {
|
|
yield()
|
|
updateSub(jsonList[i])
|
|
}
|
|
yield()
|
|
|
|
// 種別チェックより先に、cache中の最新のIDを「最後に表示した通知」に指定する
|
|
// nid_show は通知タップ時に参照されるので、通知を表示する際は必ず更新・保存する必要がある
|
|
// 種別チェックより優先する
|
|
val latestId = cache.filterLatestId(account) {
|
|
when (trackingType) {
|
|
TrackingType.Reply -> it.isMention()
|
|
TrackingType.NotReply -> !it.isMention()
|
|
else -> true
|
|
}
|
|
}
|
|
if (latestId != null) nr.nid_show = latestId
|
|
daoNotificationTracking.save(account.acct, nr)
|
|
}
|
|
|
|
private fun updateSub(src: JsonObject) {
|
|
|
|
val id = NotificationCache.getEntityOrderId(account, src)
|
|
if (id.isDefault || duplicateCheck.contains(id)) return
|
|
duplicateCheck.add(id)
|
|
|
|
// タップ・削除した通知のIDと同じか古いなら対象外
|
|
if (!id.isNewerThan(nr.nid_read)) {
|
|
log.d("update_sub: ${account.acct} skip old notification $id")
|
|
return
|
|
}
|
|
|
|
log.d("update_sub: found data that id=$id, > read id ${nr.nid_read}")
|
|
|
|
val notification = parser.notification(src) ?: return
|
|
|
|
// プッシュ通知で既出なら通知しない
|
|
// プルの場合同じ通知が何度もここを通るので、既出フラグを立てない
|
|
if (daoNotificationShown.isDuplicate(account.acct, notification.id.toString())) {
|
|
log.i("update_sub: skip duplicate. ${account.acct} ${notification.id}")
|
|
return
|
|
}
|
|
|
|
// アプリミュートと単語ミュート
|
|
if (notification.status?.checkMuted() == true) return
|
|
|
|
// ふぁぼ魔ミュート
|
|
when (notification.type) {
|
|
TootNotification.TYPE_REBLOG,
|
|
TootNotification.TYPE_FAVOURITE,
|
|
TootNotification.TYPE_FOLLOW,
|
|
TootNotification.TYPE_FOLLOW_REQUEST,
|
|
TootNotification.TYPE_FOLLOW_REQUEST_MISSKEY,
|
|
-> {
|
|
val whoAcct = notification.account
|
|
?.let { account.getFullAcct(it) }
|
|
if (whoAcct?.let { TootStatus.favMuteSet?.contains(it) } == true) {
|
|
log.d("${whoAcct.pretty} is in favMuteSet.")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mastodon 3.4.0rc1 push policy
|
|
if (!policyFilter(notification)) return
|
|
|
|
// 後から処理したものが先頭に来る
|
|
dstListData.add(0, NotificationData(account, notification))
|
|
}
|
|
|
|
fun updateNotification() {
|
|
val notificationTag = when (trackingName) {
|
|
"" -> "${account.db_id}/_"
|
|
else -> "${account.db_id}/$trackingName"
|
|
}
|
|
|
|
val nt = daoNotificationTracking
|
|
.load(account.acct, account.db_id, trackingName)
|
|
when (val first = dstListData.firstOrNull()) {
|
|
null -> {
|
|
log.d("showNotification[${account.acct.pretty}/$notificationTag] cancel notification.")
|
|
notificationManager.removeMessageNotification(account, notificationTag)
|
|
}
|
|
else -> {
|
|
when {
|
|
// 先頭にあるデータが同じなら、通知を更新しない
|
|
// このマーカーは端末再起動時にリセットされるので、再起動後は通知が出るはず
|
|
first.notification.time_created_at == nt.post_time && first.notification.id == nt.post_id ->
|
|
log.d("showNotification[${account.acct.pretty}] id=${first.notification.id} is already shown.")
|
|
|
|
PrefB.bpDivideNotification.value -> {
|
|
updateNotificationDivided(notificationTag, nt)
|
|
daoNotificationTracking.updatePost(
|
|
first.notification.id,
|
|
first.notification.time_created_at,
|
|
nt,
|
|
)
|
|
}
|
|
|
|
else -> {
|
|
updateNotificationMerged(notificationTag, first)
|
|
daoNotificationTracking.updatePost(
|
|
first.notification.id,
|
|
first.notification.time_created_at,
|
|
nt,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun updateNotificationDivided(
|
|
notificationTag: String,
|
|
nt: NotificationTracking,
|
|
) {
|
|
log.d("updateNotificationDivided[${account.acct.pretty}] creating notification(1)")
|
|
|
|
val activeNotificationMap = notificationManager.getMessageNotifications(notificationTag)
|
|
|
|
val lastPostTime = nt.post_time
|
|
val lastPostId = nt.post_id
|
|
|
|
for (item in dstListData.reversed()) {
|
|
val itemTag = "$notificationTag/${item.notification.id}"
|
|
|
|
if (lastPostId != null &&
|
|
item.notification.time_created_at <= lastPostTime &&
|
|
item.notification.id <= lastPostId
|
|
) {
|
|
// 掲載済みデータより古い通知は再表示しない
|
|
log.d("ignore $itemTag ${item.notification.time_created_at} <= $lastPostTime && ${item.notification.id} <= $lastPostId")
|
|
continue
|
|
}
|
|
|
|
// ignore if already showing
|
|
if (activeNotificationMap.remove(itemTag) != null) {
|
|
log.d("ignore $itemTag is in activeNotificationMap")
|
|
continue
|
|
}
|
|
|
|
showMessageNotification(
|
|
context,
|
|
account,
|
|
trackingType,
|
|
itemTag,
|
|
notificationId = item.notification.id.toString()
|
|
) { builder ->
|
|
builder.setWhen(item.notification.time_created_at)
|
|
val summary = item.notification.getNotificationLine(context)
|
|
builder.setContentTitle(summary)
|
|
when (val content = item.notification.status?.decoded_content?.notEmpty()) {
|
|
null -> builder.setContentText(item.accessInfo.acct.pretty)
|
|
else -> {
|
|
val style = NotificationCompat.BigTextStyle()
|
|
.setBigContentTitle(summary)
|
|
.setSummaryText(item.accessInfo.acct.pretty)
|
|
.bigText(content)
|
|
builder.setStyle(style)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// リストにない通知は消さない。ある通知をユーザが指で削除した際に他の通知が残ってほしい場合がある
|
|
}
|
|
|
|
private fun updateNotificationMerged(
|
|
notificationTag: String,
|
|
first: NotificationData,
|
|
) {
|
|
log.d("updateNotificationMerged[${account.acct.pretty}] creating notification(1)")
|
|
showMessageNotification(
|
|
context,
|
|
account,
|
|
trackingType,
|
|
notificationTag
|
|
) { builder ->
|
|
builder.setWhen(first.notification.time_created_at)
|
|
val a = first.notification.getNotificationLine(context)
|
|
val dataList = dstListData
|
|
if (dataList.size == 1) {
|
|
builder.setContentTitle(a)
|
|
builder.setContentText(account.acct.pretty)
|
|
} else {
|
|
val header = context.getString(R.string.notification_count, dataList.size)
|
|
builder.setContentTitle(header).setContentText(a)
|
|
|
|
val style = NotificationCompat.InboxStyle()
|
|
.setBigContentTitle(header)
|
|
.setSummaryText(account.acct.pretty)
|
|
|
|
for (i in 0 until min(4, dataList.size)) {
|
|
style.addLine(dataList[i].notification.getNotificationLine(context))
|
|
}
|
|
|
|
builder.setStyle(style)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|