package jp.juggler.subwaytooter.table import android.content.ContentValues import android.content.Context import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.provider.BaseColumns import org.json.JSONObject import java.util.ArrayList import java.util.Collections import java.util.Comparator import java.util.concurrent.atomic.AtomicReference import java.util.regex.Pattern import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.entity.TootAccount import jp.juggler.subwaytooter.api.entity.TootInstance import jp.juggler.subwaytooter.util.LinkHelper import jp.juggler.subwaytooter.util.LogCategory import jp.juggler.subwaytooter.util.parseString import jp.juggler.subwaytooter.util.toJsonObject class SavedAccount( val db_id : Long, val acct : String, hostArg : String? = null, var token_info : JSONObject? = null, var loginAccount : TootAccount? = null // 疑似アカウントではnull ) : LinkHelper { val username : String override val host : String var visibility : String? = null var confirm_boost : Boolean = false var confirm_favourite : Boolean = false var confirm_unboost : Boolean = false var confirm_unfavourite : Boolean = false var dont_hide_nsfw : Boolean = false var dont_show_timeout : Boolean = false var notification_mention : Boolean = false var notification_boost : Boolean = false var notification_favourite : Boolean = false var notification_follow : Boolean = false var sound_uri = "" var confirm_follow : Boolean = false var confirm_follow_locked : Boolean = false var confirm_unfollow : Boolean = false var confirm_post : Boolean = false var notification_tag : String? = null var register_key : String? = null var register_time : Long = 0 private val refInstance = AtomicReference(null) // DBには保存しない var instance : TootInstance? get() { val instance = refInstance.get() return when { instance == null -> null System.currentTimeMillis() - instance.time_parse > INSTANCE_INFORMATION_EXPIRE -> null else -> instance } } set(instance) = refInstance.set(instance) init { val pos = acct.indexOf('@') if(pos == - 1) { this.username = acct } else { this.username = acct.substring(0, pos) } if(username.isEmpty()) throw RuntimeException("missing username in acct") this.host = if(hostArg != null && hostArg.isNotEmpty()) { hostArg } else { val hostInAcct = if(pos == - 1) "" else acct.substring(pos + 1) if(hostInAcct.isEmpty()) throw RuntimeException("missing host in acct") hostInAcct }.toLowerCase() } constructor(context : Context, cursor : Cursor) : this( cursor.getLong(cursor.getColumnIndex(COL_ID)), // db_id cursor.getString(cursor.getColumnIndex(COL_USER)), // acct cursor.getString(cursor.getColumnIndex(COL_HOST)) // host ) { val jsonAccount = cursor.getString(cursor.getColumnIndex(COL_ACCOUNT)).toJsonObject() val loginAccount = TootParser( context, object : LinkHelper { override val host : String? get() = this@SavedAccount.host } ).account(jsonAccount) if(loginAccount == null) { log.e( "missing loginAccount for %s", cursor.getString(cursor.getColumnIndex(COL_ACCOUNT)) ) } this.loginAccount = loginAccount val colIdx_visibility = cursor.getColumnIndex(COL_VISIBILITY) this.visibility = if(cursor.isNull(colIdx_visibility)) null else cursor.getString(colIdx_visibility) this.confirm_boost = cursor.getInt(cursor.getColumnIndex(COL_CONFIRM_BOOST)).i2b() this.confirm_favourite = cursor.getInt(cursor.getColumnIndex(COL_CONFIRM_FAVOURITE)).i2b() this.confirm_unboost = cursor.getInt(cursor.getColumnIndex(COL_CONFIRM_UNBOOST)).i2b() this.confirm_unfavourite = cursor.getInt(cursor.getColumnIndex(COL_CONFIRM_UNFAVOURITE)).i2b() this.dont_hide_nsfw = cursor.getInt(cursor.getColumnIndex(COL_DONT_HIDE_NSFW)).i2b() this.dont_show_timeout = cursor.getInt(cursor.getColumnIndex(COL_DONT_SHOW_TIMEOUT)).i2b() this.notification_mention = cursor.getInt(cursor.getColumnIndex(COL_NOTIFICATION_MENTION)).i2b() this.notification_boost = cursor.getInt(cursor.getColumnIndex(COL_NOTIFICATION_BOOST)).i2b() this.notification_favourite = cursor.getInt(cursor.getColumnIndex(COL_NOTIFICATION_FAVOURITE)).i2b() this.notification_follow = cursor.getInt(cursor.getColumnIndex(COL_NOTIFICATION_FOLLOW)).i2b() this.confirm_follow = cursor.getInt(cursor.getColumnIndex(COL_CONFIRM_FOLLOW)).i2b() this.confirm_follow_locked = cursor.getInt(cursor.getColumnIndex(COL_CONFIRM_FOLLOW_LOCKED)).i2b() this.confirm_unfollow = cursor.getInt(cursor.getColumnIndex(COL_CONFIRM_UNFOLLOW)).i2b() this.confirm_post = cursor.getInt(cursor.getColumnIndex(COL_CONFIRM_POST)).i2b() val idx_notification_tag = cursor.getColumnIndex(COL_NOTIFICATION_TAG) this.notification_tag = if(cursor.isNull(idx_notification_tag)) null else cursor.getString(idx_notification_tag) val idx_register_key = cursor.getColumnIndex(COL_REGISTER_KEY) this.register_key = if(cursor.isNull(idx_register_key)) null else cursor.getString(idx_register_key) this.register_time = cursor.getLong(cursor.getColumnIndex(COL_REGISTER_TIME)) this.token_info = cursor.getString(cursor.getColumnIndex(COL_TOKEN)).toJsonObject() this.sound_uri = cursor.getString(cursor.getColumnIndex(COL_SOUND_URI)) } val isNA : Boolean get() = "?@?" == acct val isPseudo : Boolean get() = username == "?" fun delete() { try { App1.database.delete(table, COL_ID + "=?", arrayOf(db_id.toString())) } catch(ex : Throwable) { log.trace(ex) throw RuntimeException("SavedAccount.delete failed.", ex) } } fun updateTokenInfo(tokenInfoArg : JSONObject?) { if(db_id == INVALID_DB_ID) throw RuntimeException("updateTokenInfo: missing db_id") val token_info = tokenInfoArg ?: JSONObject() this.token_info = token_info val cv = ContentValues() cv.put(COL_TOKEN, token_info.toString()) App1.database.update(table, cv, COL_ID + "=?", arrayOf(db_id.toString())) } fun saveSetting() { if(db_id == INVALID_DB_ID) throw RuntimeException("saveSetting: missing db_id") val cv = ContentValues() cv.put(COL_VISIBILITY, visibility) cv.put(COL_CONFIRM_BOOST, confirm_boost.b2i()) cv.put(COL_CONFIRM_FAVOURITE, confirm_favourite.b2i()) cv.put(COL_CONFIRM_UNBOOST, confirm_unboost.b2i()) cv.put(COL_CONFIRM_UNFAVOURITE, confirm_unfavourite.b2i()) cv.put(COL_DONT_HIDE_NSFW, dont_hide_nsfw.b2i()) cv.put(COL_DONT_SHOW_TIMEOUT, dont_show_timeout.b2i()) cv.put(COL_NOTIFICATION_MENTION, notification_mention.b2i()) cv.put(COL_NOTIFICATION_BOOST, notification_boost.b2i()) cv.put(COL_NOTIFICATION_FAVOURITE, notification_favourite.b2i()) cv.put(COL_NOTIFICATION_FOLLOW, notification_follow.b2i()) cv.put(COL_CONFIRM_FOLLOW, confirm_follow.b2i()) cv.put(COL_CONFIRM_FOLLOW_LOCKED, confirm_follow_locked.b2i()) cv.put(COL_CONFIRM_UNFOLLOW, confirm_unfollow.b2i()) cv.put(COL_CONFIRM_POST, confirm_post.b2i()) cv.put(COL_SOUND_URI, sound_uri) // UIからは更新しない // notification_tag // register_key App1.database.update(table, cv, COL_ID + "=?", arrayOf(db_id.toString())) } fun saveNotificationTag() { if(db_id == INVALID_DB_ID) throw RuntimeException("SavedAccount.saveNotificationTag missing db_id") val cv = ContentValues() cv.put(COL_NOTIFICATION_TAG, notification_tag) App1.database.update(table, cv, COL_ID + "=?", arrayOf(db_id.toString())) } fun saveRegisterKey() { if(db_id == INVALID_DB_ID) throw RuntimeException("SavedAccount.saveRegisterKey missing db_id") val cv = ContentValues() cv.put(COL_REGISTER_KEY, register_key) cv.put(COL_REGISTER_TIME, register_time) App1.database.update(table, cv, COL_ID + "=?", arrayOf(db_id.toString())) } // onResumeの時に設定を読み直す fun reloadSetting(context : Context) { if(db_id == INVALID_DB_ID) throw RuntimeException("SavedAccount.reloadSetting missing db_id") // DBから削除されてるかもしれない val b = loadAccount(context, db_id) ?: return this.visibility = b.visibility this.confirm_boost = b.confirm_boost this.confirm_favourite = b.confirm_favourite this.confirm_unboost = b.confirm_unboost this.confirm_unfavourite = b.confirm_unfavourite this.dont_hide_nsfw = b.dont_hide_nsfw this.dont_show_timeout = b.dont_show_timeout this.token_info = b.token_info this.notification_mention = b.notification_follow this.notification_boost = b.notification_boost this.notification_favourite = b.notification_favourite this.notification_follow = b.notification_follow this.notification_tag = b.notification_tag this.sound_uri = b.sound_uri } fun getFullAcct(who : TootAccount?) : String { return getFullAcct(who?.acct) } private fun isLocalUser(acct : String?) : Boolean { acct ?: return false val pos = acct.indexOf('@') return pos == - 1 || host.equals(acct.substring(pos + 1), ignoreCase = true) } fun isLocalUser(who : TootAccount?) : Boolean { return isLocalUser(who?.acct) } fun isRemoteUser(who : TootAccount) : Boolean { return ! isLocalUser(who) } // fun isRemoteUser(acct : String) : Boolean { // return ! isLocalUser(acct) // } fun getUserUrl(who_acct : String) : String { val p = who_acct.indexOf('@') return if(- 1 != p) { "https://" + who_acct.substring(p + 1) + "/@" + who_acct.substring(0, p) } else { "https://$host/@$who_acct" } } fun isMe(who : TootAccount?) : Boolean { if(who == null || who.username != this.username) return false // val who_acct = who.acct val pos = who_acct.indexOf('@') if(pos == - 1) return true // local user have no acct return who_acct.substring(pos + 1).equals(this.host, ignoreCase = true) } fun isMe(who_acct : String) : Boolean { // 自分のユーザ名部分 var pos = this.acct.indexOf('@') val me_user = this.acct.substring(0, pos) // pos = who_acct.indexOf('@') // ローカルユーザは@以降を持たない if(pos == - 1) return who_acct == me_user // リモートユーザならホスト名部分の比較も必要 val who_user = who_acct.substring(0, pos) val who_host = who_acct.substring(pos + 1) return who_user == me_user && who_host.equals(this.host, ignoreCase = true) } fun supplyBaseUrl(url : String?) : String? { return when { url == null || url.isEmpty() -> return null url[0] == '/' -> "https://" + host + url else -> url } } fun isNicoru(account : TootAccount?) : Boolean { var host = this.host var host_start = 0 val acct = account?.acct if(acct != null) { val pos = acct.indexOf('@') if(pos != - 1) { host = account.acct host_start = pos + 1 } } return host_match(strNicoruHost, 0, host, host_start) } // implements LinkHelper override fun findAcctColor(url : String?) : AcctColor? { if(url != null) { val m = TootAccount.reAccountUrl.matcher(url) if(m.find()) return AcctColor.load(m.group(2) + "@" + m.group(1)) } return null } companion object : TableCompanion { private val log = LogCategory("SavedAccount") const val table = "access_info" private const val strNicoruHost = "friends.nico" private val reAtNicoruHost = Pattern.compile("@friends\\.nico\\z", Pattern.CASE_INSENSITIVE) private const val COL_ID = BaseColumns._ID private const val COL_HOST = "h" private const val COL_USER = "u" private const val COL_ACCOUNT = "a" private const val COL_TOKEN = "t" private const val COL_VISIBILITY = "visibility" private const val COL_CONFIRM_BOOST = "confirm_boost" private const val COL_DONT_HIDE_NSFW = "dont_hide_nsfw" // スキーマ2から private const val COL_NOTIFICATION_MENTION = "notification_mention" private const val COL_NOTIFICATION_BOOST = "notification_boost" private const val COL_NOTIFICATION_FAVOURITE = "notification_favourite" private const val COL_NOTIFICATION_FOLLOW = "notification_follow" // スキーマ10から private const val COL_CONFIRM_FOLLOW = "confirm_follow" private const val COL_CONFIRM_FOLLOW_LOCKED = "confirm_follow_locked" private const val COL_CONFIRM_UNFOLLOW = "confirm_unfollow" private const val COL_CONFIRM_POST = "confirm_post" // スキーマ13から const val COL_NOTIFICATION_TAG = "notification_server" // スキーマ14から const val COL_REGISTER_KEY = "register_key" const val COL_REGISTER_TIME = "register_time" // スキーマ16から private const val COL_SOUND_URI = "sound_uri" // スキーマ18から private const val COL_DONT_SHOW_TIMEOUT = "dont_show_timeout" // スキーマ23から private const val COL_CONFIRM_FAVOURITE = "confirm_favourite" // スキーマ24から private const val COL_CONFIRM_UNBOOST = "confirm_unboost" private const val COL_CONFIRM_UNFAVOURITE = "confirm_unfavourite" ///////////////////////////////// // login information const val INVALID_DB_ID = - 1L private const val INSTANCE_INFORMATION_EXPIRE = 60000L * 5 // アプリデータのインポート時に呼ばれる fun onDBDelete(db : SQLiteDatabase) { try { db.execSQL("drop table if exists $table") } catch(ex : Throwable) { log.trace(ex) } } override fun onDBCreate(db : SQLiteDatabase) { db.execSQL( "create table if not exists $table" + "($COL_ID INTEGER PRIMARY KEY" + ",$COL_USER text not null" + ",$COL_HOST text not null" + ",$COL_ACCOUNT text not null" + ",$COL_TOKEN text not null" + ",$COL_VISIBILITY text" + ",$COL_CONFIRM_BOOST integer default 1" + ",$COL_DONT_HIDE_NSFW integer default 0" // 以下はDBスキーマ2で追加 + ",$COL_NOTIFICATION_MENTION integer default 1" + ",$COL_NOTIFICATION_BOOST integer default 1" + ",$COL_NOTIFICATION_FAVOURITE integer default 1" + ",$COL_NOTIFICATION_FOLLOW integer default 1" // 以下はDBスキーマ10で更新 + ",$COL_CONFIRM_FOLLOW integer default 1" + ",$COL_CONFIRM_FOLLOW_LOCKED integer default 1" + ",$COL_CONFIRM_UNFOLLOW integer default 1" + ",$COL_CONFIRM_POST integer default 1" // 以下はDBスキーマ13で更新 + ",$COL_NOTIFICATION_TAG text default ''" // 以下はDBスキーマ14で更新 + ",$COL_REGISTER_KEY text default ''" + ",$COL_REGISTER_TIME integer default 0" // 以下はDBスキーマ16で更新 + ",$COL_SOUND_URI text default ''" // 以下はDBスキーマ18で更新 + ",$COL_DONT_SHOW_TIMEOUT integer default 0" // 以下はDBスキーマ23で更新 + ",$COL_CONFIRM_FAVOURITE integer default 1" // 以下はDBスキーマ24で更新 + ",$COL_CONFIRM_UNBOOST integer default 1" + ",$COL_CONFIRM_UNFAVOURITE integer default 1" + ")" ) db.execSQL("create index if not exists ${table}_user on ${table}(u)") db.execSQL("create index if not exists ${table}_host on ${table}(h,u)") } override fun onDBUpgrade(db : SQLiteDatabase, oldVersion : Int, newVersion : Int) { if(oldVersion < 2 && newVersion >= 2) { try { db.execSQL("alter table $table add column notification_mention integer default 1") } catch(ex : Throwable) { log.trace(ex) } try { db.execSQL("alter table $table add column notification_boost integer default 1") } catch(ex : Throwable) { log.trace(ex) } try { db.execSQL("alter table $table add column notification_favourite integer default 1") } catch(ex : Throwable) { log.trace(ex) } try { db.execSQL("alter table $table add column notification_follow integer default 1") } catch(ex : Throwable) { log.trace(ex) } } if(oldVersion < 10 && newVersion >= 10) { try { db.execSQL("alter table $table add column $COL_CONFIRM_FOLLOW integer default 1") } catch(ex : Throwable) { log.trace(ex) } try { db.execSQL("alter table $table add column $COL_CONFIRM_FOLLOW_LOCKED integer default 1") } catch(ex : Throwable) { log.trace(ex) } try { db.execSQL("alter table $table add column $COL_CONFIRM_UNFOLLOW integer default 1") } catch(ex : Throwable) { log.trace(ex) } try { db.execSQL("alter table $table add column $COL_CONFIRM_POST integer default 1") } catch(ex : Throwable) { log.trace(ex) } } if(oldVersion < 13 && newVersion >= 13) { try { db.execSQL("alter table $table add column $COL_NOTIFICATION_TAG text default ''") } catch(ex : Throwable) { log.trace(ex) } } if(oldVersion < 14 && newVersion >= 14) { try { db.execSQL("alter table $table add column $COL_REGISTER_KEY text default ''") } catch(ex : Throwable) { log.trace(ex) } try { db.execSQL("alter table $table add column $COL_REGISTER_TIME integer default 0") } catch(ex : Throwable) { log.trace(ex) } } if(oldVersion < 16 && newVersion >= 16) { try { db.execSQL("alter table $table add column $COL_SOUND_URI text default ''") } catch(ex : Throwable) { log.trace(ex) } } if(oldVersion < 18 && newVersion >= 18) { try { db.execSQL("alter table $table add column $COL_DONT_SHOW_TIMEOUT integer default 0") } catch(ex : Throwable) { log.trace(ex) } } if(oldVersion < 23 && newVersion >= 23) { try { db.execSQL("alter table $table add column $COL_CONFIRM_FAVOURITE integer default 1") } catch(ex : Throwable) { log.trace(ex) } } if(oldVersion < 24 && newVersion >= 24) { try { db.execSQL("alter table $table add column $COL_CONFIRM_UNFAVOURITE integer default 1") } catch(ex : Throwable) { log.trace(ex) } try { db.execSQL("alter table $table add column $COL_CONFIRM_UNBOOST integer default 1") } catch(ex : Throwable) { log.trace(ex) } } } // 横断検索用の、何とも紐ついていないアカウント // 保存しない。 val na : SavedAccount by lazy { val dst = SavedAccount(- 1L, "?@?") dst.notification_follow = false dst.notification_favourite = false dst.notification_boost = false dst.notification_mention = false dst } private fun parse(context : Context, cursor : Cursor) : SavedAccount? { return try { SavedAccount(context, cursor) } catch(ex : Throwable) { log.trace(ex) log.e(ex, "parse failed.") null } } fun insert( host : String, acct : String, account : JSONObject, token : JSONObject ) : Long { try { val cv = ContentValues() cv.put(COL_HOST, host) cv.put(COL_USER, acct) cv.put(COL_ACCOUNT, account.toString()) cv.put(COL_TOKEN, token.toString()) return App1.database.insert(table, null, cv) } catch(ex : Throwable) { log.trace(ex) throw RuntimeException("SavedAccount.insert failed.", ex) } } const val REGISTER_KEY_UNREGISTERED = "unregistered" fun clearRegistrationCache() { val cv = ContentValues() cv.put(COL_REGISTER_KEY, REGISTER_KEY_UNREGISTERED) cv.put(COL_REGISTER_TIME, 0L) App1.database.update(table, cv, null, null) } fun loadAccount(context : Context, db_id : Long) : SavedAccount? { try { App1.database.query( table, null, COL_ID + "=?", arrayOf(db_id.toString()), null, null, null ) .use { cursor -> if(cursor.moveToFirst()) { return parse(context, cursor) } log.e("moveToFirst failed. db_id=$db_id") } } catch(ex : Throwable) { log.trace(ex) log.e(ex, "loadAccount failed.") } return null } fun loadAccountList(context : Context) : ArrayList { val result = ArrayList() try { App1.database.query(table, null, null, null, null, null, null) .use { cursor -> while(cursor.moveToNext()) { val a = parse(context, cursor) if(a != null) result.add(a) } } } catch(ex : Throwable) { log.trace(ex) log.e(ex, "loadAccountList failed.") throw RuntimeException("SavedAccount.loadAccountList failed.", ex) } return result } fun loadByTag(context : Context, tag : String) : ArrayList { val result = ArrayList() try { App1.database.query( table, null, COL_NOTIFICATION_TAG + "=?", arrayOf(tag), null, null, null ) .use { cursor -> while(cursor.moveToNext()) { val a = parse(context, cursor) if(a != null) result.add(a) } } } catch(ex : Throwable) { log.trace(ex) log.e(ex, "loadByTag failed.") throw RuntimeException("SavedAccount.loadByTag failed.", ex) } return result } fun loadAccountByAcct(context : Context, full_acct : String) : SavedAccount? { try { App1.database.query( table, null, COL_USER + "=?", arrayOf(full_acct), null, null, null ) .use { cursor -> if(cursor.moveToNext()) { return parse(context, cursor) } } } catch(ex : Throwable) { log.trace(ex) log.e(ex, "loadAccountByAcct failed.") } return null } fun hasRealAccount() : Boolean { try { App1.database.query( table, null, COL_USER + " NOT LIKE '?@%'", null, null, null, null, "1" ) .use { cursor -> if(cursor.moveToNext()) { return true } } } catch(ex : Throwable) { log.trace(ex) log.e(ex, "hasNonPseudoAccount failed.") } return false } val count : Long get() { try { App1.database.query(table, arrayOf("count(*)"), null, null, null, null, null) .use { cursor -> if(cursor.moveToNext()) { return cursor.getLong(0) } } } catch(ex : Throwable) { log.trace(ex) log.e(ex, "getCount failed.") throw RuntimeException("SavedAccount.getCount failed.", ex) } return 0L } fun isNicoru(acct : String?) : Boolean { return acct != null && reAtNicoruHost.matcher(acct).find() } private fun charAtLower(src : CharSequence, pos : Int) : Char { val c = src[pos] return if(c >= 'a' && c <= 'z') c - ('a' - 'A') else c } private fun host_match( a : CharSequence, a_startArg : Int, b : CharSequence, b_startArg : Int ) : Boolean { var a_start = a_startArg var b_start = b_startArg val a_end = a.length val b_end = b.length var a_remain = a_end - a_start val b_remain = b_end - b_start // 文字数が違う if(a_remain != b_remain) return false // 文字数がゼロ if(a_remain <= 0) return true // 末尾の文字が違う if(charAtLower(a, a_end - 1) != charAtLower(b, b_end - 1)) return false // 先頭からチェック while(a_remain -- > 0) { if(charAtLower(a, a_start ++) != charAtLower(b, b_start ++)) return false } return true } private val account_comparator = Comparator { a, b -> var i : Int // NA > !NA i = a.isNA.b2i() - b.isNA.b2i() if(i != 0) return@Comparator i // pseudo > real i = a.isPseudo.b2i() - b.isPseudo.b2i() if(i != 0) return@Comparator i val sa = AcctColor.getNickname(a.acct) val sb = AcctColor.getNickname(b.acct) sa.compareTo(sb, ignoreCase = true) } fun sort(account_list : ArrayList) { Collections.sort(account_list, account_comparator) } } fun getAccessToken() : String? { return token_info?.parseString("access_token") } }