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

275 lines
11 KiB
Kotlin

package jp.juggler.subwaytooter.table
import android.content.ContentValues
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.provider.BaseColumns
import androidx.room.*
import jp.juggler.subwaytooter.api.entity.Acct
import jp.juggler.util.*
import jp.juggler.util.data.*
import jp.juggler.util.log.LogCategory
import kotlinx.coroutines.flow.MutableStateFlow
import java.util.concurrent.TimeUnit
data class PushMessage(
// DBの主ID
var id: Long = 0L,
// 通知を受け取るアカウントのacct。通知のタイトルでもある
var loginAcct: Acct? = null,
// 通知情報に含まれるタイムスタンプ
var timestamp: Long = System.currentTimeMillis(),
// 通知を受信/保存した時刻
var timeSave: Long = System.currentTimeMillis(),
// 通知を開いた/削除した時刻
var timeDismiss: Long = 0L,
// 通知ID。(loginAcct + notificationId) で重複排除する。
var notificationId: String? = null,
// 通知の種別。小アイコン、アクセント色、Misskeyの文言に影響する
var notificationType: String? = null,
// 通知に表示するための情報をまとめたJSONデータ。
var formatJson: JsonObject = JsonObject(),
// WebPushで送られたJSONデータ
var messageJson: JsonObject? = null,
// WebPushのデコードに使うJSONデータ
var headerJson: JsonObject? = null,
// アプリサーバから送られてきたバイナリデータ
var rawBody: ByteArray? = null,
) {
// 通知表示の本文
var text: String?
get() = formatJson.string(JSON_TEXT)
set(value) {
formatJson[JSON_TEXT] = value
}
// 展開表示した本文
var textExpand: String?
get() = formatJson.string(JSON_TEXT_EXPAND)
set(value) {
formatJson[JSON_TEXT_EXPAND] = value
}
// 小アイコンURL。昔のMastodonはバッジ画像が提供されていた。
var iconSmall: String?
get() = formatJson.string(JSON_ICON_SMALL)
set(value) {
formatJson[JSON_ICON_SMALL] = value
}
// 大アイコンURL。通知の原因となったユーザのアイコン画像。
var iconLarge: String?
get() = formatJson.string(JSON_ICON_LARGE)
set(value) {
formatJson[JSON_ICON_LARGE] = value
}
var formatError: String?
get() = formatJson.string(JSON_ERROR)
set(value) {
formatJson[JSON_ERROR] = value
}
companion object : TableCompanion {
private val log = LogCategory("PushMessage")
const val TABLE = "push_message"
override val table = TABLE
private const val COL_ID = BaseColumns._ID
private const val COL_LOGIN_ACCT = "login_acct"
private const val COL_TIMESTAMP = "timestamp"
private const val COL_TIME_SAVE = "time_save"
private const val COL_TIME_DISMISS = "time_dismiss"
private const val COL_NOTIFICATION_ID = "notification_id"
private const val COL_NOTIFICATION_TYPE = "notification_type"
private const val COL_FORMAT_JSON = "format_json"
private const val COL_MESSAGE_JSON = "message_json"
private const val COL_HEADER_JSON = "header_json"
private const val COL_RAW_BODY = "raw_body"
//
private const val JSON_TEXT = "text"
private const val JSON_TEXT_EXPAND = "text_expand"
private const val JSON_ICON_SMALL = "icon_small"
private const val JSON_ICON_LARGE = "icon_large"
private const val JSON_ERROR = "error"
val columnList = MetaColumns(TABLE, initialVersion = 65).apply {
deleteBeforeCreate = true
column(0, COL_ID, MetaColumns.TS_INT_PRIMARY_KEY_NOT_NULL)
column(0, COL_LOGIN_ACCT, MetaColumns.TS_TEXT_NULL)
column(0, COL_TIMESTAMP, MetaColumns.TS_ZERO_NOT_NULL)
column(0, COL_TIME_SAVE, MetaColumns.TS_ZERO_NOT_NULL)
column(0, COL_TIME_DISMISS, MetaColumns.TS_ZERO_NOT_NULL)
column(0, COL_NOTIFICATION_ID, MetaColumns.TS_TEXT_NULL)
column(0, COL_NOTIFICATION_TYPE, MetaColumns.TS_TEXT_NULL)
column(0, COL_FORMAT_JSON, MetaColumns.TS_TEXT_NULL)
column(0, COL_MESSAGE_JSON, MetaColumns.TS_TEXT_NULL)
column(0, COL_HEADER_JSON, MetaColumns.TS_TEXT_NULL)
column(0, COL_RAW_BODY, MetaColumns.TS_BLOB_NULL)
createExtra = {
arrayOf(
"create index if not exists ${TABLE}_save on $TABLE($COL_TIME_SAVE)",
"create index if not exists ${TABLE}_acct_dismiss on $TABLE($COL_LOGIN_ACCT,$COL_TIME_DISMISS)",
)
}
}
override fun onDBCreate(db: SQLiteDatabase) {
columnList.onDBCreate(db)
}
override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (oldVersion < 65 && newVersion >= 65) {
onDBCreate(db)
}
columnList.onDBUpgrade(db, oldVersion, newVersion)
}
@Suppress("MemberVisibilityCanBePrivate")
val flowDataChanged = MutableStateFlow(0L)
private fun fireDataChanged() {
flowDataChanged.value = System.currentTimeMillis()
}
}
@Suppress("MemberVisibilityCanBePrivate")
class ColIdx(cursor: Cursor) {
val idxId = cursor.getColumnIndex(COL_ID)
val idxLoginAcct = cursor.getColumnIndex(COL_LOGIN_ACCT)
val idxTimestamp = cursor.getColumnIndex(COL_TIMESTAMP)
val idxTimeSave = cursor.getColumnIndex(COL_TIME_SAVE)
val idxTimeDismiss = cursor.getColumnIndex(COL_TIME_DISMISS)
val idxNotificationId = cursor.getColumnIndex(COL_NOTIFICATION_ID)
val idxNotificationType = cursor.getColumnIndex(COL_NOTIFICATION_TYPE)
val idxFormatJson = cursor.getColumnIndex(COL_FORMAT_JSON)
val idxMessageJson = cursor.getColumnIndex(COL_MESSAGE_JSON)
val idxHeaderJson = cursor.getColumnIndex(COL_HEADER_JSON)
val idxRawBody = cursor.getColumnIndex(COL_RAW_BODY)
fun readRow(cursor: Cursor) =
PushMessage(
id = cursor.getLong(idxId),
loginAcct = cursor.getStringOrNull(idxLoginAcct)?.let{Acct.parse(it)},
timestamp = cursor.getLong(idxTimestamp),
timeSave = cursor.getLong(idxTimeSave),
timeDismiss = cursor.getLong(idxTimeDismiss),
notificationId = cursor.getStringOrNull(idxNotificationId),
notificationType = cursor.getStringOrNull(idxNotificationType),
formatJson = try {
cursor.getStringOrNull(idxFormatJson)?.decodeJsonObject()
} catch (ex: Throwable) {
log.e(ex, "can't decode formatJson.")
null
} ?: JsonObject(),
messageJson = cursor.getStringOrNull(idxMessageJson)?.decodeJsonObject(),
headerJson = cursor.getStringOrNull(idxHeaderJson)?.decodeJsonObject(),
rawBody = cursor.getBlobOrNull(idxRawBody),
)
fun readOne(cursor: Cursor) =
when (cursor.moveToNext()) {
true -> readRow(cursor)
else -> null
}
fun readAll(cursor: Cursor) = buildList {
while (cursor.moveToNext()) {
add(readRow(cursor))
}
}
}
// ID以外のカラムをContentValuesに変換する
fun toContentValues() = ContentValues().apply {
put(COL_LOGIN_ACCT, loginAcct?.ascii)
put(COL_TIMESTAMP, timestamp)
put(COL_TIME_SAVE, timeSave)
put(COL_TIME_DISMISS, timeDismiss)
put(COL_NOTIFICATION_ID, notificationId)
put(COL_NOTIFICATION_TYPE, notificationType)
put(COL_FORMAT_JSON, formatJson.toString())
put(COL_MESSAGE_JSON, messageJson?.toString())
put(COL_HEADER_JSON, headerJson?.toString())
put(COL_RAW_BODY, rawBody)
}
class Access(val db: SQLiteDatabase) {
// return id of new row
fun replace(item: PushMessage) =
item.toContentValues().replaceTo(db, TABLE)
.also { item.id = it }
.also { fireDataChanged() }
fun update(vararg items: PushMessage) =
items.sumOf { it.toContentValues().updateTo(db, TABLE, it.id.toString()) }
.also { fireDataChanged() }
fun delete(id: Long) = db.deleteById(TABLE, id.toString())
.also { fireDataChanged() }
fun save(a: PushMessage): Long {
when (a.id) {
0L -> a.id = replace(a)
else -> update(a)
}
fireDataChanged()
return a.id
}
fun find(messageId: Long): PushMessage? =
db.rawQuery(
"select * from $TABLE where $COL_ID=?",
arrayOf(messageId.toString())
)?.use { ColIdx(it).readOne(it) }
fun dismiss(messageId: Long) {
val pm = find(messageId) ?: return
if (pm.timeDismiss == 0L) {
pm.timeDismiss = System.currentTimeMillis()
update(pm)
}
}
fun dismissByAcct(acct: Acct) {
db.execSQL(
"update $table set $COL_TIME_DISMISS=? where $COL_LOGIN_ACCT=? and $COL_TIME_DISMISS=0",
arrayOf(System.currentTimeMillis().toString(), acct.ascii),
)
}
fun deleteOld(now: Long) {
try {
val expire = now - TimeUnit.DAYS.toMillis(30)
db.execSQL("delete from $TABLE where $COL_TIME_SAVE < $expire")
fireDataChanged()
} catch (ex: Throwable) {
log.e(ex, "sweep failed.")
}
}
fun listAll(): List<PushMessage> =
db.queryAll(TABLE, "$COL_TIME_SAVE desc")
?.use { ColIdx(it).readAll(it) }
?: emptyList()
fun deleteAccount(acct: Acct) {
try {
db.execSQL(
"delete from $TABLE where $COL_LOGIN_ACCT=?",
arrayOf(acct.ascii)
)
fireDataChanged()
} catch (ex: Throwable) {
log.e(ex, "sweep failed.")
}
}
}
override fun hashCode() = if (id == 0L) super.hashCode() else id.hashCode()
override fun equals(other: Any?) =
id == (other as? PushMessage)?.id
}