diff --git a/app/src/main/java/com/osfans/trime/data/DataManager.kt b/app/src/main/java/com/osfans/trime/data/DataManager.kt index 1747acf8ad..1369df59b2 100644 --- a/app/src/main/java/com/osfans/trime/data/DataManager.kt +++ b/app/src/main/java/com/osfans/trime/data/DataManager.kt @@ -2,10 +2,25 @@ package com.osfans.trime.data import com.blankj.utilcode.util.PathUtils import com.blankj.utilcode.util.ResourceUtils +import com.osfans.trime.BuildConfig +import com.osfans.trime.R import com.osfans.trime.util.Const import com.osfans.trime.util.WeakHashSet +import com.osfans.trime.util.appContext +import com.osfans.trime.util.errorRuntime +import com.osfans.trime.util.extract +import com.osfans.trime.util.withTempDir +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.encodeToStream import timber.log.Timber import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream object DataManager { private const val DEFAULT_CUSTOM_FILE_NAME = "default.custom.yaml" @@ -111,4 +126,98 @@ object DataManager { Timber.i("Synced!") } + + private val json = Json { prettyPrint = true } + + @Serializable + data class Metadata( + val packageName: String, + val versionCode: Int, + val versionName: String, + val exportTime: Long, + ) + + private fun writeFileTree( + srcDir: File, + destPrefix: String, + dest: ZipOutputStream, + ) { + dest.putNextEntry(ZipEntry("$destPrefix/")) + srcDir.walkTopDown().forEach { f -> + val related = f.relativeTo(srcDir) + if (related.path != "") { + if (f.isDirectory) { + dest.putNextEntry(ZipEntry("$destPrefix/${related.path}/")) + } else if (f.isFile) { + dest.putNextEntry(ZipEntry("$destPrefix/${related.path}")) + f.inputStream().use { it.copyTo(dest) } + } + } + } + } + + private val sharedPrefsDir = File(appContext.applicationInfo.dataDir, "shared_prefs") + private val dataBasesDir = File(appContext.applicationInfo.dataDir, "databases") + private val symbolHistoryFile = appContext.filesDir.resolve(SymbolHistory.FILE_NAME) + + @OptIn(ExperimentalSerializationApi::class) + fun export( + dest: OutputStream, + timestamp: Long = System.currentTimeMillis(), + ) = runCatching { + ZipOutputStream(dest.buffered()).use { zipStream -> + // shared_prefs + writeFileTree(sharedPrefsDir, "shared_prefs", zipStream) + // databases + writeFileTree(dataBasesDir, "databases", zipStream) + // symbol_history + zipStream.putNextEntry(ZipEntry(SymbolHistory.FILE_NAME)) + symbolHistoryFile.inputStream().use { it.copyTo(zipStream) } + // metadata + zipStream.putNextEntry(ZipEntry("metadata.json")) + val metadata = + Metadata( + BuildConfig.APPLICATION_ID, + BuildConfig.VERSION_CODE, + Const.displayVersionName, + timestamp, + ) + json.encodeToStream(metadata, zipStream) + zipStream.closeEntry() + } + } + + private fun copyDir( + source: File, + target: File, + ) { + val exists = source.exists() + val isDir = source.isDirectory + if (exists && isDir) { + source.copyRecursively(target, overwrite = true) + } else { + source.toString() + Timber.w("Cannot import user data: path='${source.path}', exists=$exists, isDir=$isDir") + } + } + + fun import(src: InputStream) = + runCatching { + ZipInputStream(src).use { zipStream -> + withTempDir { tempDir -> + val extracted = zipStream.extract(tempDir) + val metadataFile = + extracted.find { it.name == "metadata.json" } + ?: errorRuntime(R.string.exception_app_data_metadata) + val metadata = json.decodeFromString(metadataFile.readText()) + if (metadata.packageName != BuildConfig.APPLICATION_ID) { + errorRuntime(R.string.exception_app_data_package_name_mismatch) + } + copyDir(File(tempDir, "shared_prefs"), sharedPrefsDir) + copyDir(File(tempDir, "databases"), dataBasesDir) + copyDir(File(tempDir, "symbol_history"), appContext.filesDir) + metadata + } + } + } } diff --git a/app/src/main/java/com/osfans/trime/ui/fragments/OtherFragment.kt b/app/src/main/java/com/osfans/trime/ui/fragments/OtherFragment.kt index 2eed4f5ff1..ff697dc2b8 100644 --- a/app/src/main/java/com/osfans/trime/ui/fragments/OtherFragment.kt +++ b/app/src/main/java/com/osfans/trime/ui/fragments/OtherFragment.kt @@ -4,18 +4,83 @@ import android.content.ComponentName import android.content.Context import android.content.pm.PackageManager import android.os.Bundle +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatDelegate import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope import androidx.preference.ListPreference +import androidx.preference.Preference +import com.blankj.utilcode.util.ToastUtils import com.osfans.trime.R import com.osfans.trime.data.AppPrefs +import com.osfans.trime.data.DataManager import com.osfans.trime.ui.components.PaddingPreferenceFragment import com.osfans.trime.ui.main.MainViewModel +import com.osfans.trime.util.formatDateTime +import com.osfans.trime.util.iso8601UTCDateTime +import com.osfans.trime.util.queryFileName +import com.osfans.trime.util.withLoadingDialog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class OtherFragment : PaddingPreferenceFragment() { private val viewModel: MainViewModel by activityViewModels() private val prefs get() = AppPrefs.defaultInstance() + private var exportTimestamp = System.currentTimeMillis() + + private lateinit var exportLauncher: ActivityResultLauncher + + private lateinit var importLauncher: ActivityResultLauncher + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + importLauncher = + registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) return@registerForActivityResult + val ctx = requireContext() + val cr = ctx.contentResolver + lifecycleScope.withLoadingDialog(ctx) { + withContext(NonCancellable + Dispatchers.IO) { + val name = cr.queryFileName(uri) ?: return@withContext + if (!name.endsWith(".zip")) { + ctx.importErrorDialog(getString(R.string.exception_app_data_filename, name)) + return@withContext + } + try { + val inputStream = cr.openInputStream(uri)!! + val metadata = DataManager.import(inputStream).getOrThrow() + withContext(Dispatchers.Main) { + ToastUtils.showShort(getString(R.string.app_data_imported, formatDateTime(metadata.exportTime))) + } + } catch (e: Exception) { + ctx.importErrorDialog(e.localizedMessage ?: e.stackTraceToString()) + } + } + } + } + exportLauncher = + registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri -> + if (uri == null) return@registerForActivityResult + val ctx = requireContext() + lifecycleScope.withLoadingDialog(requireContext()) { + withContext(NonCancellable + Dispatchers.IO) { + try { + val outputStream = ctx.contentResolver.openOutputStream(uri)!! + DataManager.export(outputStream, exportTimestamp).getOrThrow() + } catch (e: Exception) { + e.printStackTrace() + ToastUtils.showShort(e.localizedMessage ?: e.stackTraceToString()) + } + } + } + } + } + override fun onCreatePreferences( savedInstanceState: Bundle?, rootKey: String?, @@ -32,6 +97,40 @@ class OtherFragment : PaddingPreferenceFragment() { AppCompatDelegate.setDefaultNightMode(uiMode) true } + val screen = preferenceScreen + screen.addPreference( + Preference(requireContext()).apply { + isIconSpaceReserved = false + isSingleLineTitle = false + title = getString(R.string.export_app_data) + setOnPreferenceClickListener { _ -> + lifecycleScope.launch { + exportTimestamp = System.currentTimeMillis() + exportLauncher.launch("trime_${iso8601UTCDateTime(exportTimestamp)}.zip") + } + true + } + }, + ) + screen.addPreference( + Preference(requireContext()).apply { + isIconSpaceReserved = false + isSingleLineTitle = false + title = getString(R.string.import_app_data) + setOnPreferenceClickListener { _ -> + AlertDialog.Builder(requireContext()) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setTitle(R.string.import_app_data) + .setMessage(R.string.confirm_import_app_data) + .setPositiveButton(android.R.string.ok) { _, _ -> + importLauncher.launch("application/zip") + } + .setNegativeButton(android.R.string.cancel, null) + .show() + true + } + }, + ) } override fun onResume() { @@ -54,6 +153,17 @@ class OtherFragment : PaddingPreferenceFragment() { } } + private suspend fun Context.importErrorDialog(message: String) { + withContext(Dispatchers.Main.immediate) { + AlertDialog.Builder(this@importErrorDialog) + .setTitle(R.string.import_error) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .setIconAttribute(android.R.attr.alertDialogIcon) + .show() + } + } + companion object { private const val SETTINGS_ACTIVITY_NAME = "com.osfans.trime.PrefLauncherAlias" diff --git a/app/src/main/java/com/osfans/trime/util/Content.kt b/app/src/main/java/com/osfans/trime/util/Content.kt new file mode 100644 index 0000000000..347d5c9c09 --- /dev/null +++ b/app/src/main/java/com/osfans/trime/util/Content.kt @@ -0,0 +1,12 @@ +package com.osfans.trime.util + +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns + +fun ContentResolver.queryFileName(uri: Uri): String? = + query(uri, null, null, null, null)?.use { + val index = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + it.moveToFirst() + it.getString(index) + } diff --git a/app/src/main/java/com/osfans/trime/util/Temp.kt b/app/src/main/java/com/osfans/trime/util/Temp.kt new file mode 100644 index 0000000000..054c90f534 --- /dev/null +++ b/app/src/main/java/com/osfans/trime/util/Temp.kt @@ -0,0 +1,15 @@ +package com.osfans.trime.util + +import java.io.File + +inline fun withTempDir(block: (File) -> T): T { + val dir = + appContext.cacheDir.resolve(System.currentTimeMillis().toString()).also { + it.mkdirs() + } + try { + return block(dir) + } finally { + dir.deleteRecursively() + } +} diff --git a/app/src/main/java/com/osfans/trime/util/Zip.kt b/app/src/main/java/com/osfans/trime/util/Zip.kt new file mode 100644 index 0000000000..58ee71e9a9 --- /dev/null +++ b/app/src/main/java/com/osfans/trime/util/Zip.kt @@ -0,0 +1,24 @@ +package com.osfans.trime.util + +import java.io.File +import java.util.zip.ZipInputStream + +/** + * @return top-level files in zip file + */ +fun ZipInputStream.extract(destDir: File): List { + var entry = nextEntry + val canonicalDest = destDir.canonicalPath + while (entry != null) { + if (!entry.isDirectory) { + val file = File(destDir, entry.name) + if (!file.canonicalPath.startsWith(canonicalDest)) throw SecurityException() + copyTo(file.outputStream()) + } else { + val dir = File(destDir, entry.name) + dir.mkdir() + } + entry = nextEntry + } + return destDir.listFiles()?.toList() ?: emptyList() +} diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d7d12a0348..66f7879e13 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -284,4 +284,12 @@ 没有闹钟和提醒权限,我们无法在启用定时同步时及时为您同步数据配置。 没有通知权限 没有发送通知的权限,我们无法在一些耗时操作完成时通知您。 + 导出应用数据 + 找不到应用数据的元数据 + 应用数据与当前应用不匹配 + 导入错误 + 文件 %1$s 的拓展名不指向同文的应用数据 + 已导入导出于 %1$s 的应用数据 + 导入应用数据 + 导入应用数据会覆盖本地设置与符号输入历史。继续吗? diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 4e30f7dad9..ba600ed7e5 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -285,4 +285,12 @@ 尚未賦予鬧鐘和提醒權限,因此無法在啟用定時同步時及時為您同步資料配置。 沒有通知權限 尚未賦予通知權限,因此無法在耗時較長的操作完成後給您發送通知。 + 匯出應用資料 + 找不到應用資料的元資料 + 應用資料與當前應用不匹配 + 匯入錯誤 + 檔案 %1$s 的拓展名不指向同文的應用資料 + 已匯入匯出於 %1$s 的應用資料 + 匯入應用資料 + 匯入應用資料會覆蓋本地設定與符號輸入歷史。繼續嗎? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8f4bad9763..c056218cd0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -286,4 +286,12 @@ Without the permmission to schedule exact alarm, we are not able to sync your profile in time when you enable syncing on schedule. No Notification Permission Without the permission to post notifications, we are not able to notify you when some time-consuming operations are done. + Export app data + Unable to find metadata of app data + App data does not match current application + Import Error + The filename extension of %1$s does not indicate trime app data + App data on %1$s has been imported + Import app data + Import app data would override local settings and symbol history. Process?