SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/appsetting/AppDataExporter.kt

478 lines
16 KiB
Kotlin

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<String>()
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<Long, Long>?) {
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<Long, Long>,
) = ArrayList<Column>().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<Column> {
var result: ArrayList<Column>? = null
val app_state = App1.getAppState(context)
reader.beginObject()
val account_id_map = HashMap<Long, Long>()
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<Column>?,
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
}
}