package jp.juggler.subwaytooter.appsetting import android.annotation.SuppressLint import android.content.ContentValues import android.content.Context import android.content.SharedPreferences import android.database.Cursor import android.net.Uri import android.provider.BaseColumns import android.util.JsonReader import android.util.JsonToken import android.util.JsonWriter import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.AppState import jp.juggler.subwaytooter.api.entity.EntityId import jp.juggler.subwaytooter.column.Column import jp.juggler.subwaytooter.column.ColumnEncoder import jp.juggler.subwaytooter.column.getBackgroundImageDir import jp.juggler.subwaytooter.pref.PrefL import jp.juggler.subwaytooter.pref.impl.* import jp.juggler.subwaytooter.pref.lazyPref import jp.juggler.subwaytooter.table.* import jp.juggler.util.* import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory import java.io.File import java.io.FileOutputStream import java.io.IOException import java.io.InputStream import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream object AppDataExporter { internal val log = LogCategory("AppDataExporter") @Suppress("FloatingPointLiteralPrecision") private const val MAGIC_NAN = -76287755398823900.0 private const val KEY_PREF = "pref" private const val KEY_ACCOUNT = "account" private const val KEY_COLUMN = "column" private const val KEY_ACCT_COLOR = "acct_color" private const val KEY_MUTED_APP = "muted_app" private const val KEY_MUTED_WORD = "muted_word" private const val KEY_FAV_MUTE = "fav_mute" // v3.4.5で廃止 private const val KEY_CLIENT_INFO = "client_info2" private const val KEY_HIGHLIGHT_WORD = "highlight_word" @Throws(IOException::class, JsonException::class) private fun JsonWriter.writeJsonValue(v: Any?) { when (v) { null -> nullValue() is String -> value(v) is Boolean -> value(v) is Number -> value(v) is EntityId -> value(v.toString()) is JsonObject -> { beginObject() for (entry in v.entries) { name(entry.key) writeJsonValue(entry.value) } endObject() } is JsonArray -> { beginArray() for (value in v) { writeJsonValue(v) } endArray() } else -> error("writeJsonValue: bad value type: $v") } } @Throws(IOException::class, JsonException::class) private fun JsonReader.readJsonValue(): Any? { return when (peek()) { JsonToken.NULL -> { nextNull() null } JsonToken.STRING -> nextString() JsonToken.BOOLEAN -> nextBoolean() JsonToken.NUMBER -> nextDouble() JsonToken.BEGIN_OBJECT -> buildJsonObject { beginObject() while (hasNext()) { val name = nextName() val value = readJsonValue() put(name, value) } endObject() } JsonToken.BEGIN_ARRAY -> buildJsonArray { beginArray() while (hasNext()) { add(readJsonValue()) } endArray() } else -> null } } @Throws(IOException::class) private fun writeFromTable(writer: JsonWriter, jsonKey: String, table: String) { writer.name(jsonKey) writer.beginArray() appDatabase.rawQuery("select from $table", emptyArray()).use { cursor -> val names = ArrayList() val column_count = cursor.columnCount for (i in 0 until column_count) { names.add(cursor.getColumnName(i)) } while (cursor.moveToNext()) { writer.beginObject() for (i in 0 until column_count) { when (cursor.getType(i)) { Cursor.FIELD_TYPE_NULL -> { writer.name(names[i]) writer.nullValue() } Cursor.FIELD_TYPE_INTEGER -> { writer.name(names[i]) writer.value(cursor.getLong(i)) } Cursor.FIELD_TYPE_STRING -> { writer.name(names[i]) writer.value(cursor.getString(i)) } Cursor.FIELD_TYPE_FLOAT -> { val d = cursor.getDouble(i) if (d.isNaN() || d.isInfinite()) { log.w("column ${names[i]} is nan or infinite value.") } else { writer.name(names[i]) writer.value(d) } } Cursor.FIELD_TYPE_BLOB -> log.w("column ${names[i]} is blob.") } } writer.endObject() } } writer.endArray() } @Throws(IOException::class) private fun importTable(reader: JsonReader, table: String, idMap: HashMap?) { val db = appDatabase if (table == SavedAccount.table) { SavedAccount.onDBDelete(db) SavedAccount.onDBCreate(db) } db.execSQL("BEGIN TRANSACTION") try { db.execSQL("delete from $table") val cv = ContentValues() reader.beginArray() while (reader.hasNext()) { var old_id = -1L cv.clear() reader.beginObject() while (reader.hasNext()) { val name = reader.nextName() if (name == null) { reader.skipValue() continue } if (BaseColumns._ID == name) { old_id = reader.nextLong() continue } if (SavedAccount.table == table) { when (name) { // 一時的に存在したが現在のDBスキーマにはない項目は読み飛ばす "nickname", "color", "notification_server", "register_key", "register_time", "last_notification_error", "last_subscription_error", "last_push_endpoint", -> { reader.skipValue() continue } else -> Unit } } when (reader.peek()) { JsonToken.NULL -> { reader.skipValue() cv.putNull(name) } JsonToken.BOOLEAN -> cv.put(name, if (reader.nextBoolean()) 1 else 0) JsonToken.NUMBER -> cv.put(name, reader.nextLong()) JsonToken.STRING -> cv.put(name, reader.nextString()) else -> reader.skipValue() } } reader.endObject() val new_id = db.replace(table, null, cv) if (new_id == -1L) error("importTable: invalid row_id") idMap?.put(old_id, new_id) } reader.endArray() db.execSQL("COMMIT TRANSACTION") } catch (ex: Throwable) { log.e(ex, "importTable failed.") try { db.execSQL("ROLLBACK TRANSACTION") } catch (ignored: Throwable) { } throw ex } } @Throws(IOException::class) private fun writePref(writer: JsonWriter, pref: SharedPreferences) { writer.name(KEY_PREF) writer.beginObject() for ((k, v) in pref.all) { writer.name(k) when (v) { null -> writer.nullValue() is String -> writer.value(v) is Boolean -> writer.value(v) is Number -> when { (v is Double && v.isNaN()) -> writer.value(MAGIC_NAN) (v is Float && v.isNaN()) -> writer.value(MAGIC_NAN) else -> writer.value(v) } else -> error("writePref: bad data type. key=$k, type=${v.javaClass.simpleName}") } } writer.endObject() } @Throws(IOException::class) private fun importPref(reader: JsonReader, pref: SharedPreferences) { val e = pref.edit() reader.beginObject() while (reader.hasNext()) { val k = reader.nextName() ?: error("importPref: name is null") val token = reader.peek() if (token == JsonToken.NULL) { reader.nextNull() e.remove(k) continue } when (val prefItem = BasePref.allPref[k]) { is BooleanPref -> e.putBoolean(k, reader.nextBoolean()) is IntPref -> e.putInt(k, reader.nextInt()) is LongPref -> e.putLong(k, reader.nextLong()) is StringPref -> if (prefItem.skipImport) { reader.skipValue() e.remove(k) } else { e.putString(k, reader.nextString()) } is FloatPref -> { val dv = reader.nextDouble() e.putFloat(k, if (dv <= MAGIC_NAN) Float.NaN else dv.toFloat()) } else -> { // ignore or force reset reader.skipValue() e.remove(k) } } } reader.endObject() e.apply() } @Throws(IOException::class, JsonException::class) private fun writeColumn(appState: AppState, writer: JsonWriter) { writer.name(KEY_COLUMN) writer.beginArray() for (column in appState.columnList) { writer.writeJsonValue(buildJsonObject { ColumnEncoder.encode(column, this, 0) }) } writer.endArray() } @Throws(IOException::class, JsonException::class) private fun readColumn( appState: AppState, reader: JsonReader, idMap: HashMap, ) = ArrayList().also { result -> reader.beginArray() while (reader.hasNext()) { val item: JsonObject = reader.readJsonValue().cast()!! // DB上のアカウントIDが変化したので置き換える when (val old_id = item.long(ColumnEncoder.KEY_ACCOUNT_ROW_ID) ?: -1L) { // 検索カラムのアカウントIDはNAアカウントと紐ついている。変換の必要はない -1L -> { } else -> item[ColumnEncoder.KEY_ACCOUNT_ROW_ID] = idMap[old_id] ?: error("readColumn: can't convert account id") } try { result.add(Column(appState, item)) } catch (ex: Throwable) { log.e(ex, "column load failed.") throw ex } } reader.endArray() } @Throws(IOException::class, JsonException::class) fun encodeAppData(context: Context, writer: JsonWriter) { writer.setIndent(" ") writer.beginObject() val app_state = App1.getAppState(context) writePref(writer, lazyPref) writeFromTable(writer, KEY_ACCOUNT, SavedAccount.table) writeFromTable(writer, KEY_ACCT_COLOR, AcctColor.table) writeFromTable(writer, KEY_MUTED_APP, MutedApp.table) writeFromTable(writer, KEY_MUTED_WORD, MutedWord.table) writeFromTable(writer, KEY_FAV_MUTE, FavMute.table) writeFromTable(writer, KEY_HIGHLIGHT_WORD, HighlightWord.table) writeColumn(app_state, writer) writer.endObject() } @SuppressLint("UseSparseArrays") @Throws(IOException::class, JsonException::class) internal fun decodeAppData(context: Context, reader: JsonReader): ArrayList { var result: ArrayList? = null val app_state = App1.getAppState(context) reader.beginObject() val account_id_map = HashMap() while (reader.hasNext()) { when (reader.nextName()) { KEY_PREF -> importPref(reader, lazyPref) KEY_ACCOUNT -> importTable(reader, SavedAccount.table, account_id_map) KEY_ACCT_COLOR -> { importTable(reader, AcctColor.table, null) daoAcctColor.clearMemoryCache() } KEY_MUTED_APP -> importTable(reader, MutedApp.table, null) KEY_MUTED_WORD -> importTable(reader, MutedWord.table, null) KEY_FAV_MUTE -> importTable(reader, FavMute.table, null) KEY_HIGHLIGHT_WORD -> importTable(reader, HighlightWord.table, null) KEY_COLUMN -> result = readColumn(app_state, reader, account_id_map) // 端末間でクライアントIDを再利用することはできなくなった // KEY_CLIENT_INFO -> importTable(reader, ClientInfo.table, null) else -> reader.skipValue() } } run { val old_id = PrefL.lpTabletTootDefaultAccount.value if (old_id != -1L) { val new_id = account_id_map[old_id] PrefL.lpTabletTootDefaultAccount.value = new_id ?: -1L } } if (result == null) error("import data does not includes column list!") return result } fun saveBackgroundImage( context: Context, zipStream: ZipOutputStream, column: Column, ) { try { column.columnBgImage.mayUri()?.let { uri -> context.contentResolver.openInputStream(uri)?.use { inStream -> zipStream.putNextEntry(ZipEntry("background-image/${column.columnId}")) try { inStream.copyTo(zipStream) } finally { zipStream.closeEntry() } } } } catch (ex: Throwable) { log.e(ex, "saveBackgroundImage failed.") } } private val reBackgroundImage = "background-image/(.+)".asciiPattern() // エントリが背景画像のソレなら真を返す // column.column_bg_image を更新する場合がある fun restoreBackgroundImage( context: Context, columnList: ArrayList?, inStream: InputStream, entryName: String, ): Boolean { // entryName がバックグラウンド画像のそれと一致するか val m = reBackgroundImage.matcher(entryName) if (!m.find()) return false try { val id = m.groupEx(1) val column = columnList?.find { it.columnId == id } if (column == null) { log.e("missing column for id $id") } else { val backgroundDir = getBackgroundImageDir(context) val file = File(backgroundDir, "${column.columnId}:${System.currentTimeMillis()}") FileOutputStream(file).use { outStream -> inStream.copyTo(outStream) } column.columnBgImage = Uri.fromFile(file).toString() } } catch (ex: Throwable) { log.e(ex, "restoreBackgroundImage failed.") } return true } }