SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/table/NotificationCache.kt

377 lines
12 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

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.table
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import android.provider.BaseColumns
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.api.ApiPath
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootApiResult
import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.TootNotification
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.util.*
import java.util.*
import kotlin.collections.ArrayList
class NotificationCache(private val account_db_id: Long) {
private var id = -1L
// サーバから通知を取得した時刻
private var last_load: Long = 0
// 通知のリスト
var data = ArrayList<JsonObject>()
companion object : TableCompanion {
private val log = LogCategory("NotificationCache")
override val table = "noti_cache"
private const val COL_ID = BaseColumns._ID
// アカウントDBの行ID。 サーバ側のIDではない
private const val COL_ACCOUNT_DB_ID = "a"
// サーバから通知を取得した時刻
private const val COL_LAST_LOAD = "l"
// サーバから最後に読んだデータ。既読は排除されてるかも
private const val COL_DATA = "d"
// サーバから最後に読んだデータ。既読は排除されてるかも
private const val COL_SINCE_ID = "si" // 使わなくなった
override fun onDBCreate(db: SQLiteDatabase) {
db.execSQL(
"""
create table if not exists $table
($COL_ID INTEGER PRIMARY KEY
,$COL_ACCOUNT_DB_ID integer not null
,$COL_LAST_LOAD integer default 0
,$COL_DATA text
,$COL_SINCE_ID text
)
"""
)
db.execSQL(
"create unique index if not exists ${table}_a on $table ($COL_ACCOUNT_DB_ID)"
)
}
override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (oldVersion < 41 && newVersion >= 41) {
onDBCreate(db)
return
}
}
private const val WHERE_AID = "$COL_ACCOUNT_DB_ID=?"
private const val KEY_TIME_CREATED_AT = "<>KEY_TIME_CREATED_AT"
fun resetLastLoad(dbId: Long) {
try {
val cv = ContentValues()
cv.put(COL_LAST_LOAD, 0L)
App1.database.update(table, cv, WHERE_AID, arrayOf(dbId.toString()))
} catch (ex: Throwable) {
log.e(ex, "resetLastLoad(db_id) failed.")
}
}
fun resetLastLoad() {
try {
val cv = ContentValues()
cv.put(COL_LAST_LOAD, 0L)
App1.database.update(table, cv, null, null)
} catch (ex: Throwable) {
log.e(ex, "resetLastLoad() failed.")
}
}
fun getEntityOrderId(account: SavedAccount, src: JsonObject): EntityId =
if (account.isMisskey) {
// 今のMisskeyはIDをIDとして使っても問題ないのだが、
// ST的には既読チェックの値の内容が大幅に変わると困るのだった
when (val created_at = src.string("createdAt")) {
null -> EntityId.DEFAULT
else -> EntityId(TootStatus.parseTime(created_at).toString())
}
} else {
EntityId.mayDefault(src.string("id"))
}
private fun makeNotificationUrl(
accessInfo: SavedAccount,
flags: Int,
sinceId: EntityId?,
) = when {
// MisskeyはsinceIdを指定すると未読範囲の古い方から読んでしまう
accessInfo.isMisskey -> "/api/i/notifications"
else -> {
val sb = StringBuilder(ApiPath.PATH_NOTIFICATIONS) // always contain "?limit=XX"
if (sinceId != null) sb.append("&since_id=$sinceId")
fun noBit(v: Int, mask: Int) = (v and mask) != mask
if (noBit(flags, 1)) sb.append("&exclude_types[]=reblog")
if (noBit(flags, 2)) sb.append("&exclude_types[]=favourite")
if (noBit(flags, 4)) sb.append("&exclude_types[]=follow")
if (noBit(flags, 8)) sb.append("&exclude_types[]=mention")
// if(noBit(flags,16)) /* mastodon has no reaction */
if (noBit(flags, 32)) sb.append("&exclude_types[]=poll")
sb.toString()
}
}
fun parseNotificationTime(accessInfo: SavedAccount, src: JsonObject): Long =
when {
accessInfo.isMisskey -> TootStatus.parseTime(src.string("createdAt"))
else -> TootStatus.parseTime(src.string("created_at"))
}
fun parseNotificationType(accessInfo: SavedAccount, src: JsonObject): String =
when {
accessInfo.isMisskey -> src.string("type")
else -> src.string("type")
} ?: "?"
fun deleteCache(dbId: Long) {
try {
val cv = ContentValues()
cv.put(COL_ACCOUNT_DB_ID, dbId)
cv.put(COL_LAST_LOAD, 0L)
cv.putNull(COL_DATA)
App1.database.replaceOrThrow(table, null, cv)
} catch (ex: Throwable) {
log.e(ex, "deleteCache failed.")
}
}
}
// load into this object
fun load() {
try {
App1.database.query(
table,
null,
WHERE_AID,
arrayOf(account_db_id.toString()),
null,
null,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
this.id = cursor.getLong(COL_ID)
this.last_load = cursor.getLong(COL_LAST_LOAD)
cursor.getStringOrNull(COL_DATA)?.decodeJsonArray()?.objectList()?.let {
data.addAll(it)
}
} else {
this.id = -1
this.last_load = 0L
}
}
} catch (ex: Throwable) {
log.trace(ex, "load failed.")
}
}
fun save() {
try {
val cv = ContentValues()
cv.put(COL_ACCOUNT_DB_ID, account_db_id)
cv.put(COL_LAST_LOAD, last_load)
cv.put(COL_DATA, data.toJsonArray().toString())
val rv = App1.database.replaceOrThrow(table, null, cv)
if (rv != -1L && id == -1L) id = rv
} catch (ex: Throwable) {
log.e(ex, "save failed.")
}
}
private fun normalize(account: SavedAccount) {
// 新しい順に並べる
data.sortWith { a, b ->
val la = a.optLong(KEY_TIME_CREATED_AT)
val lb = b.optLong(KEY_TIME_CREATED_AT)
when {
la < lb -> 1
la > lb -> -1
else -> 0
}
}
val typeCount = HashMap<String, Int>()
val it = data.iterator()
val duplicateMap = HashSet<EntityId>()
while (it.hasNext()) {
val item = it.next()
val id = getEntityOrderId(account, item)
if (id.isDefault) {
it.remove()
continue
}
// skip duplicated
if (duplicateMap.contains(id)) {
it.remove()
continue
}
duplicateMap.add(id)
val type = parseNotificationType(account, item)
// 通知しないタイプなら取り除く
if (!account.canNotificationShowing(type)) {
it.remove()
continue
}
// 種類別に一定件数を保持する
val count = 1 + (typeCount[type] ?: 0)
if (count > 60) {
it.remove()
continue
}
typeCount[type] = count
}
}
suspend fun requestAsync(
client: TootApiClient,
account: SavedAccount,
flags: Int,
onError: (TootApiResult) -> Unit,
isCancelled: () -> Boolean,
) {
val now = System.currentTimeMillis()
// 前回の更新から一定時刻が経過するまでは処理しない
val remain = last_load + 120000L - now
if (remain > 0) {
log.d("${account.acct} skip request. wait ${remain}ms.")
return
}
this.last_load = now
// キャッシュ更新時は全データの最新データより新しいものを読みたい
val newestId = data
.mapNotNull { getEntityOrderId(account, it).takeIf { id -> !id.isDefault } }
.reduceOrNull { a, b -> maxComparable(a, b) }
val path = makeNotificationUrl(account, flags, newestId)
try {
for (nTry in 0..3) {
if (isCancelled()) {
log.d("cancelled.")
return
}
val result = if (account.isMisskey) {
client.request(path, account.putMisskeyApiToken().toPostRequestBuilder())
} else {
client.request(path)
}
if (result == null) {
log.d("cancelled.")
return
}
val array = result.jsonArray
if (array != null) {
account.updateNotificationError(null)
// データをマージする
array.objectList().forEach { item ->
item[KEY_TIME_CREATED_AT] = parseNotificationTime(account, item)
data.add(item)
}
normalize(account)
return
}
log.d("request error. ${result.error} ${result.requestInfo}")
account.updateNotificationError("${result.error} ${result.requestInfo}".trim())
onError(result)
// サーバからエラー応答が届いているならリトライしない
val code = result.response?.code
if (code != null && code in 200 until 600) {
break
}
}
} catch (ex: Throwable) {
log.trace(ex, "request failed.")
} finally {
save()
}
}
inline fun filterLatestId(account: SavedAccount, predicate: (JsonObject) -> Boolean) =
data
.filter { predicate(it) }
.mapNotNull { getEntityOrderId(account, it).takeIf { id -> !id.isDefault } }
.reduceOrNull { a, b -> maxComparable(a, b) }
fun inject(account: SavedAccount, list: List<TootNotification>) {
try {
val jsonList = list.map { it.json }
jsonList.forEach { item ->
item[KEY_TIME_CREATED_AT] = parseNotificationTime(account, item)
}
data.addAll(jsonList)
normalize(account)
} catch (ex: Throwable) {
log.trace(ex, "inject failed.")
} finally {
save()
}
}
//
//
//
// fun updatePost(post_id : EntityId, post_time : Long) {
// this.post_id = post_id
// this.post_time = post_time
// try {
// val cv = ContentValues()
// post_id.putTo(cv, COL_POST_ID)
// cv.put(COL_POST_TIME, post_time)
// val rows = App1.database.update(table, cv, WHERE_AID, arrayOf(account_db_id.toString()))
// log.d(
// "updatePost account_db_id=%s,post=%s,%s last_data=%s,update_rows=%s"
// , account_db_id
// , post_id
// , post_time
// , last_data?.length
// , rows
// )
//
// } catch(ex : Throwable) {
// log.e(ex, "updatePost failed.")
// }
//
// }
}