Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support to Import and Export App Internal Data #1277

Open
wants to merge 2 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions app/src/main/java/com/osfans/trime/data/DataManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<Metadata>(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
}
}
}
}
110 changes: 110 additions & 0 deletions app/src/main/java/com/osfans/trime/ui/fragments/OtherFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>

private lateinit var importLauncher: ActivityResultLauncher<String>

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?,
Expand All @@ -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() {
Expand All @@ -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"

Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/com/osfans/trime/util/Content.kt
Original file line number Diff line number Diff line change
@@ -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)
}
15 changes: 15 additions & 0 deletions app/src/main/java/com/osfans/trime/util/Temp.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.osfans.trime.util

import java.io.File

inline fun <T> withTempDir(block: (File) -> T): T {
val dir =
appContext.cacheDir.resolve(System.currentTimeMillis().toString()).also {
it.mkdirs()
}
try {
return block(dir)
} finally {
dir.deleteRecursively()
}
}
24 changes: 24 additions & 0 deletions app/src/main/java/com/osfans/trime/util/Zip.kt
Original file line number Diff line number Diff line change
@@ -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<File> {
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()
}
8 changes: 8 additions & 0 deletions app/src/main/res/values-zh-rCN/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -284,4 +284,12 @@
<string name="schedule_exact_alarm_permission_message">没有闹钟和提醒权限,我们无法在启用定时同步时及时为您同步数据配置。</string>
<string name="notification_permission_title">没有通知权限</string>
<string name="notification_permission_message">没有发送通知的权限,我们无法在一些耗时操作完成时通知您。</string>
<string name="export_app_data">导出应用数据</string>
<string name="exception_app_data_metadata">找不到应用数据的元数据</string>
<string name="exception_app_data_package_name_mismatch">应用数据与当前应用不匹配</string>
<string name="import_error">导入错误</string>
<string name="exception_app_data_filename">文件 %1$s 的拓展名不指向同文的应用数据</string>
<string name="app_data_imported">已导入导出于 %1$s 的应用数据</string>
<string name="import_app_data">导入应用数据</string>
<string name="confirm_import_app_data">导入应用数据会覆盖本地设置与符号输入历史。继续吗?</string>
</resources>
8 changes: 8 additions & 0 deletions app/src/main/res/values-zh-rTW/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -285,4 +285,12 @@
<string name="schedule_exact_alarm_permission_message">尚未賦予鬧鐘和提醒權限,因此無法在啟用定時同步時及時為您同步資料配置。</string>
<string name="notification_permission_title">沒有通知權限</string>
<string name="notification_permission_message">尚未賦予通知權限,因此無法在耗時較長的操作完成後給您發送通知。</string>
<string name="export_app_data">匯出應用資料</string>
<string name="exception_app_data_metadata">找不到應用資料的元資料</string>
<string name="exception_app_data_package_name_mismatch">應用資料與當前應用不匹配</string>
<string name="import_error">匯入錯誤</string>
<string name="exception_app_data_filename">檔案 %1$s 的拓展名不指向同文的應用資料</string>
<string name="app_data_imported">已匯入匯出於 %1$s 的應用資料</string>
<string name="import_app_data">匯入應用資料</string>
<string name="confirm_import_app_data">匯入應用資料會覆蓋本地設定與符號輸入歷史。繼續嗎?</string>
</resources>
8 changes: 8 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -286,4 +286,12 @@
<string name="schedule_exact_alarm_permission_message">Without the permmission to schedule exact alarm, we are not able to sync your profile in time when you enable syncing on schedule.</string>
<string name="notification_permission_title">No Notification Permission</string>
<string name="notification_permission_message">Without the permission to post notifications, we are not able to notify you when some time-consuming operations are done.</string>
<string name="export_app_data">Export app data</string>
<string name="exception_app_data_metadata">Unable to find metadata of app data</string>
<string name="exception_app_data_package_name_mismatch">App data does not match current application</string>
<string name="import_error">Import Error</string>
<string name="exception_app_data_filename">The filename extension of %1$s does not indicate trime app data</string>
<string name="app_data_imported">App data on %1$s has been imported</string>
<string name="import_app_data">Import app data</string>
<string name="confirm_import_app_data">Import app data would override local settings and symbol history. Process?</string>
</resources>
Loading