package jp.juggler.subwaytooter.table import android.content.ContentValues import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.provider.BaseColumns import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.entity.Acct import jp.juggler.subwaytooter.api.entity.EntityId import jp.juggler.subwaytooter.api.entity.TootAccount import jp.juggler.subwaytooter.api.entity.TootRelationShip import jp.juggler.util.* class UserRelation { var following = false // 認証ユーザからのフォロー状態にある var followed_by = false // 認証ユーザは被フォロー状態にある var blocking = false // 認証ユーザからブロックした var blocked_by = false // 認証ユーザからブロックされた(Misskeyのみ。Mastodonでは常にfalse) var muting = false var requested = false // 認証ユーザからのフォローは申請中である var requested_by = false // 相手から認証ユーザへのフォローリクエスト申請中(Misskeyのみ。Mastodonでは常にfalse) var following_reblogs = 0 // このユーザからのブーストをTLに表示する var endorsed = false // ユーザをプロフィールで紹介する var notifying = false // ユーザの投稿を通知する var note: String? = null // 認証ユーザからのフォロー状態 fun getFollowing(who: TootAccount?): Boolean { return if (requested && !following && who != null && !who.locked) true else following } // 認証ユーザからのフォローリクエスト申請中状態 fun getRequested(who: TootAccount?): Boolean { return if (requested && !following && who != null && !who.locked) false else requested } companion object : TableCompanion { const val REBLOG_HIDE = 0 // don't show the boosts from target account will be shown on authorized user's home TL. const val REBLOG_SHOW = 1 // show the boosts from target account will be shown on authorized user's home TL. const val REBLOG_UNKNOWN = 2 // not following, or instance don't support hide reblog. private val mMemoryCache = androidx.collection.LruCache(2048) private val log = LogCategory("UserRelationMisskey") override val table = "user_relation_misskey" val columnList: ColumnMeta.List = ColumnMeta.List(table, 30).apply { createExtra = { arrayOf( "create unique index if not exists ${table}_id on $table($COL_DB_ID,$COL_WHO_ID)", "create index if not exists ${table}_time on $table($COL_TIME_SAVE)", ) } deleteBeforeCreate = true } val COL_ID = ColumnMeta(columnList, 0, BaseColumns._ID, "INTEGER PRIMARY KEY", primary = true) private val COL_TIME_SAVE = ColumnMeta(columnList, 0, "time_save", "integer not null") // SavedAccount のDB_ID。 疑似アカウント用のエントリは -2L private val COL_DB_ID = ColumnMeta(columnList, 0, "db_id", "integer not null") // ターゲットアカウントのID val COL_WHO_ID = ColumnMeta(columnList, 0, "who_id", "text not null") private val COL_FOLLOWING = ColumnMeta(columnList, 0, "following", "integer not null") private val COL_FOLLOWED_BY = ColumnMeta(columnList, 0, "followed_by", "integer not null") private val COL_BLOCKING = ColumnMeta(columnList, 0, "blocking", "integer not null") private val COL_MUTING = ColumnMeta(columnList, 0, "muting", "integer not null") private val COL_REQUESTED = ColumnMeta(columnList, 0, "requested", "integer not null") private val COL_FOLLOWING_REBLOGS = ColumnMeta(columnList, 0, "following_reblogs", "integer not null") private val COL_ENDORSED = ColumnMeta(columnList, 32, "endorsed", "integer default 0") private val COL_BLOCKED_BY = ColumnMeta(columnList, 34, "blocked_by", "integer default 0") private val COL_REQUESTED_BY = ColumnMeta(columnList, 35, "requested_by", "integer default 0") private val COL_NOTE = ColumnMeta(columnList, 55, "note", "text default null") private val COL_NOTIFYING = ColumnMeta(columnList, 58, "notifying", "integer default 0") private const val DB_ID_PSEUDO = -2L override fun onDBCreate(db: SQLiteDatabase) = columnList.onDBCreate(db) override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = columnList.onDBUpgrade(db, oldVersion, newVersion) fun deleteOld(now: Long) { try { val expire = now - 86400000L * 365 App1.database.delete(table, "$COL_TIME_SAVE) { val db = App1.database db.execSQL("BEGIN TRANSACTION") val bOK = try { val cv = ContentValues() cv.put(COL_TIME_SAVE, now) cv.put(COL_DB_ID, dbId) for (src in srcList) { val id = src.id.toString() cv.put(COL_WHO_ID, id) cv.fromTootRelationShip(src) db.replaceOrThrow(table, null, cv) } true } catch (ex: Throwable) { log.trace(ex) log.e(ex, "saveList failed.") false } when { !bOK -> db.execSQL("ROLLBACK TRANSACTION") else -> { db.execSQL("COMMIT TRANSACTION") for (src in srcList) { mMemoryCache.remove(key(dbId, src.id)) } } } } fun save1Misskey(now: Long, dbId: Long, whoId: String, src: UserRelation?) { src ?: return try { ContentValues().apply { put(COL_TIME_SAVE, now) put(COL_DB_ID, dbId) put(COL_WHO_ID, whoId) fromUserRelation(src) }.let { App1.database.replaceOrThrow(table, null, it) } mMemoryCache.remove(key(dbId, whoId)) } catch (ex: Throwable) { log.e(ex, "save failed.") } } fun saveListMisskey( now: Long, dbId: Long, srcList: List>, start: Int, end: Int, ) { val db = App1.database db.execSQL("BEGIN TRANSACTION") val bOK = try { val cv = ContentValues() cv.put(COL_TIME_SAVE, now) cv.put(COL_DB_ID, dbId) for (i in start until end) { val entry = srcList[i] val id = entry.key val src = entry.value cv.put(COL_WHO_ID, id.toString()) cv.fromUserRelation(src) db.replaceOrThrow(table, null, cv) } true } catch (ex: Throwable) { log.trace(ex) log.e(ex, "saveList failed.") false } when { !bOK -> db.execSQL("ROLLBACK TRANSACTION") else -> { db.execSQL("COMMIT TRANSACTION") for (i in start until end) { val entry = srcList[i] val key = key(dbId, entry.key) mMemoryCache.remove(key) } } } } // Misskeyのリレーション取得APIから fun saveListMisskeyRelationApi(now: Long, dbId: Long, list: ArrayList) { val db = App1.database db.execSQL("BEGIN TRANSACTION") val bOK = try { val cv = ContentValues() cv.put(COL_TIME_SAVE, now) cv.put(COL_DB_ID, dbId) for (src in list) { val id = src.id.toString() cv.put(COL_WHO_ID, id) cv.fromTootRelationShip(src) db.replace(table, null, cv) } true } catch (ex: Throwable) { log.trace(ex) log.e(ex, "saveListMisskeyRelationApi failed.") false } when { !bOK -> db.execSQL("ROLLBACK TRANSACTION") else -> { db.execSQL("COMMIT TRANSACTION") for (src in list) { mMemoryCache.remove(key(dbId, src.id)) } } } } private val loadWhere = "$COL_DB_ID=? and $COL_WHO_ID=?" private val loadWhereArg = object : ThreadLocal>() { override fun initialValue(): Array = Array(2) { null } } fun load(dbId: Long, whoId: EntityId): UserRelation { // val key = key(dbId, whoId) val cached: UserRelation? = mMemoryCache.get(key) if (cached != null) return cached val dst = load(dbId, whoId.toString()) mMemoryCache.put(key, dst) return dst } fun load(dbId: Long, whoId: String): UserRelation { try { val where_arg = loadWhereArg.get() ?: arrayOfNulls(2) where_arg[0] = dbId.toString() where_arg[1] = whoId App1.database.query(table, null, loadWhere, where_arg, null, null, null) .use { cursor -> if (cursor.moveToNext()) { val dst = UserRelation() dst.following = cursor.getBoolean(COL_FOLLOWING) dst.followed_by = cursor.getBoolean(COL_FOLLOWED_BY) dst.blocking = cursor.getBoolean(COL_BLOCKING) dst.muting = cursor.getBoolean(COL_MUTING) dst.requested = cursor.getBoolean(COL_REQUESTED) dst.following_reblogs = cursor.getInt(COL_FOLLOWING_REBLOGS) dst.endorsed = cursor.getBoolean(COL_ENDORSED) dst.blocked_by = cursor.getBoolean(COL_BLOCKED_BY) dst.requested_by = cursor.getBoolean(COL_REQUESTED_BY) dst.notifying = cursor.getBoolean(COL_NOTIFYING) dst.note = cursor.getStringOrNull(COL_NOTE) return dst } } } catch (ex: Throwable) { log.trace(ex) log.e(ex, "load failed.") } return UserRelation() } // MisskeyはUserエンティティにユーザリレーションが含まれたり含まれなかったりする fun fromAccount(parser: TootParser, src: JsonObject, id: EntityId) { // アカウントのjsonがユーザリレーションを含まないなら何もしない src["isFollowing"] ?: return // プロフカラムで ユーザのプロフ(A)とアカウントTL(B)を順に取得すると // (A)ではisBlockingに情報が入っているが、(B)では情報が入っていない // 対策として(A)でリレーションを取得済みのユーザは(B)のタイミングではリレーションを読み捨てる val map = parser.misskeyUserRelationMap if (map.containsKey(id)) return map[id] = UserRelation().apply { following = src.optBoolean("isFollowing") followed_by = src.optBoolean("isFollowed") muting = src.optBoolean("isMuted") blocking = src.optBoolean("isBlocking") blocked_by = src.optBoolean("isBlocked") endorsed = false requested = src.optBoolean("hasPendingFollowRequestFromYou") requested_by = src.optBoolean("hasPendingFollowRequestToYou") } } fun loadPseudo(acct: Acct) = load(DB_ID_PSEUDO, acct.ascii) fun createCursorPseudo(): Cursor = App1.database.query( table, arrayOf(COL_ID.name, COL_WHO_ID.name), "$COL_DB_ID=$DB_ID_PSEUDO and ( $COL_MUTING=1 or $COL_BLOCKING=1 )", null, null, null, "$COL_WHO_ID asc" ) fun deletePseudo(rowId: Long) { try { App1.database.delete(table, "$COL_ID=$rowId", null) } catch (ex: Throwable) { log.trace(ex) } } } fun savePseudo(acct: String) = save1Misskey(System.currentTimeMillis(), DB_ID_PSEUDO, acct, this) }