package jp.juggler.subwaytooter.table import android.content.ContentValues import android.database.sqlite.SQLiteDatabase import android.provider.BaseColumns import jp.juggler.subwaytooter.api.entity.Acct import jp.juggler.subwaytooter.api.entity.EntityId import jp.juggler.subwaytooter.api.entity.putMayNull import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory import java.util.concurrent.ConcurrentHashMap class NotificationTracking { private var dirty = false private var id = -1L set(value) { dirty = true; field = value } // アカウントDBの行ID。 サーバ側のIDではない private var accountDbId: Long = 0 set(value) { dirty = true; field = value } // 返信だけ通知グループを分ける private var notificationType: String = "" set(value) { dirty = true; field = value } // 通知ID。ここまで既読 var nid_read: EntityId? = null set(value) { dirty = true; field = value } // 通知ID。もっとも最近取得したもの var nid_show: EntityId? = null set(value) { dirty = true; field = value } // 最後に表示した通知のID var post_id: EntityId? = null set(value) { dirty = true; field = value } // 最後に表示した通知の作成時刻 var post_time: Long = 0 set(value) { dirty = true; field = value } companion object : TableCompanion { private val log = LogCategory("NotificationTracking") override val table = "noti_trac" private const val COL_ID = BaseColumns._ID private const val COL_ACCOUNT_DB_ID = "a" private const val COL_NID_READ = "nr" private const val COL_NID_SHOW = "ns" private const val COL_POST_ID = "pi" private const val COL_POST_TIME = "pt" private const val COL_NOTIFICATION_TYPE = "nt" private val metaColumns = MetaColumns(table, initialVersion = 0).apply { column(0, COL_ID, "INTEGER PRIMARY KEY") column(0, COL_ACCOUNT_DB_ID, "integer not null") column(0, COL_NID_READ, "text") column(0, COL_NID_SHOW, "text") column(0, COL_POST_ID, "text") column(0, COL_POST_TIME, "integer default 0") column(40, COL_NOTIFICATION_TYPE, "text default ''") deleteBeforeCreate = true createExtra = { arrayOf( "create unique index if not exists ${table}_b on $table($COL_ACCOUNT_DB_ID,$COL_NOTIFICATION_TYPE)" ) } } override fun onDBCreate(db: SQLiteDatabase) { metaColumns.onDBCreate(db) } override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { if (newVersion < oldVersion) { try { db.execSQL("drop table if exists $table") } catch (ex: Throwable) { log.e(ex, "delete DB failed.") } metaColumns.onDBCreate(db) return } if (oldVersion < 2 && newVersion >= 2) { metaColumns.onDBCreate(db) return } if (oldVersion < 40 && newVersion >= 40) { try { db.execSQL("drop index if exists ${table}_a") } catch (ex: Throwable) { log.e(ex, "drop index failed.") } } metaColumns.onDBUpgrade(db, oldVersion, newVersion) if (oldVersion < 40 && newVersion >= 40) { try { db.execSQL( "create unique index if not exists ${table}_b on $table ($COL_ACCOUNT_DB_ID,$COL_NOTIFICATION_TYPE)" ) } catch (ex: Throwable) { log.e(ex, "can't add index.") } } } private val cache = ConcurrentHashMap>() private fun ConcurrentHashMap.getOrCreate( key: K, creator: () -> V, ): V { var v = this[key] if (v == null) v = creator().also { this[key] = it } return v } private fun loadCache(accountDbId: Long, notificationType: String): NotificationTracking? = cache[accountDbId]?.get(notificationType) private fun clearCache(accountDbId: Long, notificationType: String): NotificationTracking? = cache[accountDbId]?.remove(notificationType) private fun saveCache( accountDbId: Long, notificationType: String, nt: NotificationTracking, ) { cache.getOrCreate(accountDbId) { ConcurrentHashMap() }[notificationType] = nt } ///////////////////////////////////////////////////////////////////////////////// private const val WHERE_AID = "$COL_ACCOUNT_DB_ID=? and $COL_NOTIFICATION_TYPE=?" } fun toContentValues() = ContentValues().apply { put(COL_ACCOUNT_DB_ID, accountDbId) put(COL_NOTIFICATION_TYPE, notificationType) nid_read.putMayNull(this, COL_NID_READ) nid_show.putMayNull(this, COL_NID_SHOW) post_id.putMayNull(this, COL_POST_ID) put(COL_POST_TIME, post_time) } class Access(val db: SQLiteDatabase) { fun load( // ログ出力だけに使う acct: Acct, accountDbId: Long, notificationType: String, ): NotificationTracking { loadCache(accountDbId, notificationType) ?.takeIf { !it.dirty } ?.let { log.d( "${acct.pretty}/$notificationType load-cached. post=(${it.post_id},${it.post_time}), read=${it.nid_read}, show=${it.nid_show}" ) return it } val whereArgs = arrayOf(accountDbId.toString(), notificationType) val dst = NotificationTracking() dst.accountDbId = accountDbId dst.notificationType = notificationType try { db.rawQuery( "select * from $table where $COL_ACCOUNT_DB_ID=? and $COL_NOTIFICATION_TYPE=?", whereArgs, )?.use { cursor -> if (cursor.moveToFirst()) { dst.id = cursor.getLong(COL_ID) dst.post_id = EntityId.entityId(cursor, COL_POST_ID) dst.post_time = cursor.getLong(COL_POST_TIME) val show = EntityId.entityId(cursor, COL_NID_SHOW) if (show == null) { dst.nid_show = null dst.nid_read = null } else { dst.nid_show = show val read = EntityId.entityId(cursor, COL_NID_READ) if (read == null) { dst.nid_read = null } else { val r2 = minComparable(show, read) dst.nid_read = r2 if (r2 != read) { log.i("$acct/$notificationType read>show! clip to $show") val cv = ContentValues() show.putTo(cv, COL_NID_READ) //変数名とキー名が異なるのに注意 db.update(table, cv, WHERE_AID, whereArgs) } } } log.d( "${acct.pretty}/$notificationType load. post=(${dst.post_id},${dst.post_time}), read=${dst.nid_read}, show=${dst.nid_show}" ) saveCache(accountDbId, notificationType, dst) } } } catch (ex: Throwable) { log.e(ex, "${acct.pretty} load failed.") } finally { dst.dirty = false } return dst } fun updateRead(accountDbId: Long, notificationType: String) { try { val whereArgs = arrayOf(accountDbId.toString(), notificationType) db.rawQuery( "select $COL_NID_SHOW, $COL_NID_READ from $table where $COL_ACCOUNT_DB_ID=? and $COL_NOTIFICATION_TYPE=?", whereArgs, )?.use { cursor -> when { !cursor.moveToFirst() -> log.e("updateRead[$accountDbId,$notificationType]: can't find the data row.") else -> { val nid_show = EntityId.entityId(cursor, COL_NID_SHOW) val nid_read = EntityId.entityId(cursor, COL_NID_READ) when { nid_show == null -> log.e("updateRead[$accountDbId,$notificationType]: nid_show is null.") nid_read != null && nid_read >= nid_show -> log.e("updateRead[$accountDbId,$notificationType]: nid_read already updated.") else -> { log.i("updateRead[$accountDbId,$notificationType]: update nid_read as $nid_show...") val cv = ContentValues() nid_show.putTo(cv, COL_NID_READ) //変数名とキー名が異なるのに注意 db.update(table, cv, WHERE_AID, whereArgs) clearCache(accountDbId, notificationType) } } } } } } catch (ex: Throwable) { log.e(ex, "updateRead[$accountDbId] failed.") } } fun resetPostAll() { try { val cv = ContentValues() cv.putNull(COL_POST_ID) cv.put(COL_POST_TIME, 0) db.update(table, cv, null, null) cache.clear() } catch (ex: Throwable) { log.e(ex, "resetPostAll failed.") } } // アカウント設定から手動で呼ばれる fun resetTrackingState(accountDbId: Long?) { accountDbId ?: return try { db.delete(table, "$COL_ACCOUNT_DB_ID=?", arrayOf(accountDbId.toString())) cache.remove(accountDbId) } catch (ex: Throwable) { log.e(ex, "resetTrackingState failed.") } } fun save( // ログ出力だけに使う acct: Acct, item: NotificationTracking, ) { try { if (!item.dirty) return val rowId = item.toContentValues() .replaceTo(db, table) if (item.id == -1L) item.id = rowId log.d("${acct.pretty}/${item.notificationType} save. post=(${item.post_id},${item.post_time})") item.dirty = false clearCache(item.accountDbId, item.notificationType) } catch (ex: Throwable) { log.e(ex, "save failed.") } } fun updatePost(postId: EntityId, postTime: Long, item: NotificationTracking) { try { item.post_id = postId item.post_time = postTime if (!item.dirty) return val cv = item.toContentValues().apply { postId.putTo(this, COL_POST_ID) put(COL_POST_TIME, postTime) } val rows = db.update( table, cv, WHERE_AID, arrayOf(item.accountDbId.toString(), item.notificationType) ) log.d("updatePost account_db_id=${item.accountDbId}, nt=${item.notificationType}, post=${postId},${postTime} update_rows=${rows}") item.dirty = false clearCache(item.accountDbId, item.notificationType) } catch (ex: Throwable) { log.e(ex, "updatePost failed.") } } } }