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

377 lines
12 KiB
Kotlin
Raw Normal View History

package jp.juggler.subwaytooter.table
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import android.provider.BaseColumns
import jp.juggler.subwaytooter.App1
2021-05-17 16:13:04 +02:00
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 -> {
2021-05-17 16:13:04 +02:00
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.")
// }
//
// }
}