perf: import/export data
Some checks are pending
Build-Apk / build (push) Waiting to run

This commit is contained in:
lisonge 2024-07-07 22:20:40 +08:00
parent a57fe608f9
commit 58f367b72b
6 changed files with 107 additions and 115 deletions

View File

@ -1,19 +1,33 @@
package li.songe.gkd.data
import android.content.Context
import android.net.Uri
import com.blankj.utilcode.util.LogUtils
import com.blankj.utilcode.util.UriUtils
import com.blankj.utilcode.util.ZipUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import li.songe.gkd.db.DbSet
import li.songe.gkd.util.LOCAL_SUBS_IDS
import li.songe.gkd.util.checkSubsUpdate
import li.songe.gkd.util.exportZipDir
import li.songe.gkd.util.importZipDir
import li.songe.gkd.util.json
import li.songe.gkd.util.resetDirectory
import li.songe.gkd.util.shareFile
import li.songe.gkd.util.subsIdToRawFlow
import li.songe.gkd.util.subsItemsFlow
import li.songe.gkd.util.toast
import li.songe.gkd.util.updateSubscription
@Serializable
data class TransferData(
private data class TransferData(
val type: String = TYPE,
val ctime: Long = System.currentTimeMillis(),
val subsItems: List<SubsItem> = emptyList(),
val subscriptions: List<RawSubscription> = emptyList(),
val subsConfigs: List<SubsConfig> = emptyList(),
val categoryConfigs: List<CategoryConfig> = emptyList(),
) {
@ -22,16 +36,7 @@ data class TransferData(
}
}
suspend fun exportTransferData(subsItemIds: Collection<Long>): TransferData {
return TransferData(
subsItems = subsItemsFlow.value.filter { subsItemIds.contains(it.id) },
subscriptions = subsIdToRawFlow.value.values.filter { it.id < 0 && subsItemIds.contains(it.id) },
subsConfigs = DbSet.subsConfigDao.querySubsItemConfig(subsItemIds.toList()),
categoryConfigs = DbSet.categoryConfigDao.querySubsItemConfig(subsItemIds.toList()),
)
}
suspend fun importTransferData(transferData: TransferData): Boolean {
private suspend fun importTransferData(transferData: TransferData): Boolean {
// TODO transaction
val maxOrder = (subsItemsFlow.value.maxOfOrNull { it.order } ?: -1) + 1
val subsItems =
@ -44,10 +49,68 @@ suspend fun importTransferData(transferData: TransferData): Boolean {
DbSet.subsItemDao.insertOrIgnore(*subsItems.toTypedArray())
DbSet.subsConfigDao.insertOrIgnore(*transferData.subsConfigs.toTypedArray())
DbSet.categoryConfigDao.insertOrIgnore(*transferData.categoryConfigs.toTypedArray())
transferData.subscriptions.forEach { subscription ->
return hasNewSubsItem
}
suspend fun exportData(context: Context, subsIds: Collection<Long>) {
if (subsIds.isEmpty()) return
exportZipDir.resetDirectory()
val dataFile = exportZipDir.resolve("${TransferData.TYPE}.json")
dataFile.writeText(
json.encodeToString(
TransferData(
subsItems = subsItemsFlow.value.filter { subsIds.contains(it.id) },
subsConfigs = DbSet.subsConfigDao.querySubsItemConfig(subsIds.toList()),
categoryConfigs = DbSet.categoryConfigDao.querySubsItemConfig(subsIds.toList()),
)
)
)
val files = exportZipDir.resolve("files").apply { mkdir() }
subsIdToRawFlow.value.values.filter { it.id < 0 && subsIds.contains(it.id) }.forEach {
val file = files.resolve("${it.id}.json")
file.writeText(json.encodeToString(it))
}
val file = exportZipDir.resolve("backup-${System.currentTimeMillis()}.zip")
ZipUtils.zipFiles(listOf(dataFile, files), file)
dataFile.delete()
files.deleteRecursively()
context.shareFile(file, "分享数据文件")
}
suspend fun importData(uri: Uri) {
importZipDir.resetDirectory()
val zipFile = importZipDir.resolve("import.zip")
zipFile.writeBytes(UriUtils.uri2Bytes(uri))
val unZipImportFile = importZipDir.resolve("unzipImport")
ZipUtils.unzipFile(zipFile, unZipImportFile)
val transferFile = unZipImportFile.resolve("${TransferData.TYPE}.json")
if (!transferFile.exists() || !transferFile.isFile) {
toast("导入无数据")
return
}
val data = withContext(Dispatchers.Default) {
json.decodeFromString<TransferData>(transferFile.readText())
}
val hasNewSubsItem = importTransferData(data)
val files = unZipImportFile.resolve("files")
val subscriptions = (files.listFiles { f -> f.isFile && f.name.endsWith(".json") }
?: emptyArray()).mapNotNull { f ->
try {
RawSubscription.parse(f.readText())
} catch (e: Exception) {
LogUtils.d(e)
null
}
}
subscriptions.forEach { subscription ->
if (LOCAL_SUBS_IDS.contains(subscription.id)) {
updateSubscription(subscription)
}
}
return hasNewSubsItem
toast("导入成功")
importZipDir.resetDirectory()
if (hasNewSubsItem) {
delay(1000)
checkSubsUpdate(true)
}
}

View File

@ -1,6 +1,5 @@
package li.songe.gkd.ui.component
import android.content.Context
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Arrangement
@ -33,27 +32,21 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.blankj.utilcode.util.ClipboardUtils
import com.blankj.utilcode.util.ZipUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.encodeToString
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.TransferData
import li.songe.gkd.data.deleteSubscription
import li.songe.gkd.data.exportTransferData
import li.songe.gkd.data.exportData
import li.songe.gkd.ui.destinations.CategoryPageDestination
import li.songe.gkd.ui.destinations.GlobalRulePageDestination
import li.songe.gkd.ui.destinations.SubsPageDestination
import li.songe.gkd.util.LOCAL_SUBS_ID
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.exportZipDir
import li.songe.gkd.util.formatTimeAgo
import li.songe.gkd.util.json
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.map
import li.songe.gkd.util.navigate
import li.songe.gkd.util.openUri
import li.songe.gkd.util.shareFile
import li.songe.gkd.util.subsLoadErrorsFlow
import li.songe.gkd.util.subsRefreshErrorsFlow
import li.songe.gkd.util.subsRefreshingFlow
@ -249,7 +242,7 @@ private fun SubsMenuItem(
onClick = {
onExpandedChange(false)
vm.viewModelScope.launchTry(Dispatchers.IO) {
context.shareSubs(subItem.id)
exportData(context, listOf(subItem.id))
}
}
)
@ -296,14 +289,3 @@ private fun SubsMenuItem(
}
}
}
suspend fun Context.shareSubs(vararg subsIds: Long) {
val transferDataFile = exportZipDir.resolve("${TransferData.TYPE}.json")
transferDataFile.writeText(
json.encodeToString(exportTransferData(subsIds.toList()))
)
val file = exportZipDir.resolve("backup-${System.currentTimeMillis()}.zip")
ZipUtils.zipFiles(listOf(transferDataFile), file)
transferDataFile.delete()
this.shareFile(file, "分享数据文件")
}

View File

@ -15,22 +15,15 @@ import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.viewModelScope
import com.blankj.utilcode.util.LogUtils
import com.blankj.utilcode.util.UriUtils
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import li.songe.gkd.MainActivity
import li.songe.gkd.OpenFileActivity
import li.songe.gkd.OpenSchemeActivity
import li.songe.gkd.data.TransferData
import li.songe.gkd.data.importTransferData
import li.songe.gkd.data.importData
import li.songe.gkd.util.ProfileTransitions
import li.songe.gkd.util.checkSubsUpdate
import li.songe.gkd.util.json
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.readFileZipByteArray
import li.songe.gkd.util.toast
data class BottomNavItem(
@ -53,8 +46,7 @@ fun HomePage() {
val pages = arrayOf(controlPage, subsPage, appListPage, settingsPage)
val currentPage = pages.find { p -> p.navItem.label == tab.label }
?: controlPage
val currentPage = pages.find { p -> p.navItem.label == tab.label } ?: controlPage
val intent = context.intent
LaunchedEffect(key1 = intent, block = {
@ -67,23 +59,7 @@ fun HomePage() {
vm.viewModelScope.launchTry(Dispatchers.IO) {
toast("加载导入...")
vm.tabFlow.value = subsPage.navItem
val string = readFileZipByteArray(
UriUtils.uri2Bytes(uri),
"${TransferData.TYPE}.json"
)
if (string != null) {
val transferData = withContext(Dispatchers.Default) {
json.decodeFromString<TransferData>(string)
}
val hasNewSubsItem = importTransferData(transferData)
toast("导入成功")
if (hasNewSubsItem) {
delay(1000)
checkSubsUpdate(true)
}
} else {
toast("导入失败")
}
importData(uri)
}
} else if (source == OpenSchemeActivity::class.qualifiedName) {
LogUtils.d(uri)
@ -97,8 +73,7 @@ fun HomePage() {
bottomBar = {
NavigationBar {
pages.forEach { page ->
NavigationBarItem(
selected = tab.label == page.navItem.label,
NavigationBarItem(selected = tab.label == page.navItem.label,
modifier = Modifier,
onClick = {
vm.tabFlow.value = page.navItem
@ -111,8 +86,7 @@ fun HomePage() {
},
label = {
Text(text = page.navItem.label)
}
)
})
}
}
},

View File

@ -53,27 +53,23 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.viewModelScope
import com.blankj.utilcode.util.UriUtils
import com.dylanc.activityresult.launcher.launchForResult
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import li.songe.gkd.data.TransferData
import li.songe.gkd.data.Value
import li.songe.gkd.data.deleteSubscription
import li.songe.gkd.data.importTransferData
import li.songe.gkd.data.exportData
import li.songe.gkd.data.importData
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.component.SubsItemCard
import li.songe.gkd.ui.component.getDialogResult
import li.songe.gkd.ui.component.shareSubs
import li.songe.gkd.util.LOCAL_SUBS_ID
import li.songe.gkd.util.LocalLauncher
import li.songe.gkd.util.checkSubsUpdate
import li.songe.gkd.util.isSafeUrl
import li.songe.gkd.util.json
import li.songe.gkd.util.launchAsFn
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.readFileZipByteArray
import li.songe.gkd.util.subsIdToRawFlow
import li.songe.gkd.util.subsItemsFlow
import li.songe.gkd.util.subsRefreshingFlow
@ -225,7 +221,7 @@ fun useSubsManagePage(): ScaffoldExt {
}
}
IconButton(onClick = vm.viewModelScope.launchAsFn(Dispatchers.IO) {
context.shareSubs(*selectedIds.toLongArray())
exportData(context, selectedIds)
}) {
Icon(
imageVector = Icons.Default.Share,
@ -305,17 +301,7 @@ fun useSubsManagePage(): ScaffoldExt {
toast("未选择文件")
return@launchAsFn
}
val string = readFileZipByteArray(
UriUtils.uri2Bytes(uri), "${TransferData.TYPE}.json"
)
if (string != null) {
val transferData =
json.decodeFromString<TransferData>(string)
importTransferData(transferData)
toast("导入成功")
} else {
toast("导入文件无数据")
}
importData(uri)
},
)
}

View File

@ -21,6 +21,7 @@ val newVersionApkDir by lazy { cacheDir.resolve("newVersionApk") }
val logZipDir by lazy { cacheDir.resolve("logZip") }
val imageCacheDir by lazy { cacheDir.resolve("imageCache") }
val exportZipDir by lazy { cacheDir.resolve("exportZip") }
val importZipDir by lazy { cacheDir.resolve("exportZip") }
fun initFolder() {
listOf(
@ -31,7 +32,8 @@ fun initFolder() {
newVersionApkDir,
logZipDir,
imageCacheDir,
exportZipDir
exportZipDir,
importZipDir
).forEach { f ->
if (!f.exists()) {
// TODO 在某些机型上无法创建目录 用户反馈重启手机后解决 是否存在其它解决方式?
@ -46,18 +48,27 @@ fun clearCache() {
newVersionApkDir,
logZipDir,
imageCacheDir,
exportZipDir
exportZipDir,
importZipDir
).forEach { dir ->
if (dir.isDirectory && dir.exists()) {
dir.listFiles()?.forEach { file ->
if (file.isFile) {
file.delete()
}
}
dir.deleteRecursively()
dir.mkdir()
}
}
}
fun File.resetDirectory() {
if (isFile) {
delete()
} else if (isDirectory) {
deleteRecursively()
}
if (!exists()) {
mkdir()
}
}
fun buildLogFile(): File {
val files = mutableListOf(dbFolder, subsFolder)

View File

@ -1,24 +0,0 @@
package li.songe.gkd.util
import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.InputStreamReader
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
fun readFileZipByteArray(zipByteArray: ByteArray, fileName: String): String? {
val byteArrayInputStream = ByteArrayInputStream(zipByteArray)
val zipInputStream = ZipInputStream(byteArrayInputStream)
zipInputStream.use {
var zipEntry: ZipEntry? = zipInputStream.nextEntry
while (zipEntry != null) {
if (zipEntry.name == fileName) {
val reader = BufferedReader(InputStreamReader(zipInputStream))
val content = reader.use { it.readText() }
return content
}
zipEntry = zipInputStream.nextEntry
}
}
return null
}