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 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.Serializable
import kotlinx.serialization.encodeToString
import li.songe.gkd.db.DbSet import li.songe.gkd.db.DbSet
import li.songe.gkd.util.LOCAL_SUBS_IDS 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.subsIdToRawFlow
import li.songe.gkd.util.subsItemsFlow import li.songe.gkd.util.subsItemsFlow
import li.songe.gkd.util.toast
import li.songe.gkd.util.updateSubscription import li.songe.gkd.util.updateSubscription
@Serializable @Serializable
data class TransferData( private data class TransferData(
val type: String = TYPE, val type: String = TYPE,
val ctime: Long = System.currentTimeMillis(), val ctime: Long = System.currentTimeMillis(),
val subsItems: List<SubsItem> = emptyList(), val subsItems: List<SubsItem> = emptyList(),
val subscriptions: List<RawSubscription> = emptyList(),
val subsConfigs: List<SubsConfig> = emptyList(), val subsConfigs: List<SubsConfig> = emptyList(),
val categoryConfigs: List<CategoryConfig> = emptyList(), val categoryConfigs: List<CategoryConfig> = emptyList(),
) { ) {
@ -22,16 +36,7 @@ data class TransferData(
} }
} }
suspend fun exportTransferData(subsItemIds: Collection<Long>): TransferData { private suspend fun importTransferData(transferData: TransferData): Boolean {
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 {
// TODO transaction // TODO transaction
val maxOrder = (subsItemsFlow.value.maxOfOrNull { it.order } ?: -1) + 1 val maxOrder = (subsItemsFlow.value.maxOfOrNull { it.order } ?: -1) + 1
val subsItems = val subsItems =
@ -44,10 +49,68 @@ suspend fun importTransferData(transferData: TransferData): Boolean {
DbSet.subsItemDao.insertOrIgnore(*subsItems.toTypedArray()) DbSet.subsItemDao.insertOrIgnore(*subsItems.toTypedArray())
DbSet.subsConfigDao.insertOrIgnore(*transferData.subsConfigs.toTypedArray()) DbSet.subsConfigDao.insertOrIgnore(*transferData.subsConfigs.toTypedArray())
DbSet.categoryConfigDao.insertOrIgnore(*transferData.categoryConfigs.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)) { if (LOCAL_SUBS_IDS.contains(subscription.id)) {
updateSubscription(subscription) 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 package li.songe.gkd.ui.component
import android.content.Context
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -33,27 +32,21 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.blankj.utilcode.util.ClipboardUtils import com.blankj.utilcode.util.ClipboardUtils
import com.blankj.utilcode.util.ZipUtils
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.encodeToString
import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsItem import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.TransferData
import li.songe.gkd.data.deleteSubscription 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.CategoryPageDestination
import li.songe.gkd.ui.destinations.GlobalRulePageDestination import li.songe.gkd.ui.destinations.GlobalRulePageDestination
import li.songe.gkd.ui.destinations.SubsPageDestination import li.songe.gkd.ui.destinations.SubsPageDestination
import li.songe.gkd.util.LOCAL_SUBS_ID import li.songe.gkd.util.LOCAL_SUBS_ID
import li.songe.gkd.util.LocalNavController import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.exportZipDir
import li.songe.gkd.util.formatTimeAgo import li.songe.gkd.util.formatTimeAgo
import li.songe.gkd.util.json
import li.songe.gkd.util.launchTry import li.songe.gkd.util.launchTry
import li.songe.gkd.util.map import li.songe.gkd.util.map
import li.songe.gkd.util.navigate import li.songe.gkd.util.navigate
import li.songe.gkd.util.openUri import li.songe.gkd.util.openUri
import li.songe.gkd.util.shareFile
import li.songe.gkd.util.subsLoadErrorsFlow import li.songe.gkd.util.subsLoadErrorsFlow
import li.songe.gkd.util.subsRefreshErrorsFlow import li.songe.gkd.util.subsRefreshErrorsFlow
import li.songe.gkd.util.subsRefreshingFlow import li.songe.gkd.util.subsRefreshingFlow
@ -249,7 +242,7 @@ private fun SubsMenuItem(
onClick = { onClick = {
onExpandedChange(false) onExpandedChange(false)
vm.viewModelScope.launchTry(Dispatchers.IO) { 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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.blankj.utilcode.util.LogUtils import com.blankj.utilcode.util.LogUtils
import com.blankj.utilcode.util.UriUtils
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph import com.ramcosta.composedestinations.annotation.RootNavGraph
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import li.songe.gkd.MainActivity import li.songe.gkd.MainActivity
import li.songe.gkd.OpenFileActivity import li.songe.gkd.OpenFileActivity
import li.songe.gkd.OpenSchemeActivity import li.songe.gkd.OpenSchemeActivity
import li.songe.gkd.data.TransferData import li.songe.gkd.data.importData
import li.songe.gkd.data.importTransferData
import li.songe.gkd.util.ProfileTransitions 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.launchTry
import li.songe.gkd.util.readFileZipByteArray
import li.songe.gkd.util.toast import li.songe.gkd.util.toast
data class BottomNavItem( data class BottomNavItem(
@ -53,8 +46,7 @@ fun HomePage() {
val pages = arrayOf(controlPage, subsPage, appListPage, settingsPage) val pages = arrayOf(controlPage, subsPage, appListPage, settingsPage)
val currentPage = pages.find { p -> p.navItem.label == tab.label } val currentPage = pages.find { p -> p.navItem.label == tab.label } ?: controlPage
?: controlPage
val intent = context.intent val intent = context.intent
LaunchedEffect(key1 = intent, block = { LaunchedEffect(key1 = intent, block = {
@ -67,23 +59,7 @@ fun HomePage() {
vm.viewModelScope.launchTry(Dispatchers.IO) { vm.viewModelScope.launchTry(Dispatchers.IO) {
toast("加载导入...") toast("加载导入...")
vm.tabFlow.value = subsPage.navItem vm.tabFlow.value = subsPage.navItem
val string = readFileZipByteArray( importData(uri)
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("导入失败")
}
} }
} else if (source == OpenSchemeActivity::class.qualifiedName) { } else if (source == OpenSchemeActivity::class.qualifiedName) {
LogUtils.d(uri) LogUtils.d(uri)
@ -97,8 +73,7 @@ fun HomePage() {
bottomBar = { bottomBar = {
NavigationBar { NavigationBar {
pages.forEach { page -> pages.forEach { page ->
NavigationBarItem( NavigationBarItem(selected = tab.label == page.navItem.label,
selected = tab.label == page.navItem.label,
modifier = Modifier, modifier = Modifier,
onClick = { onClick = {
vm.tabFlow.value = page.navItem vm.tabFlow.value = page.navItem
@ -111,8 +86,7 @@ fun HomePage() {
}, },
label = { label = {
Text(text = page.navItem.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.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.blankj.utilcode.util.UriUtils
import com.dylanc.activityresult.launcher.launchForResult import com.dylanc.activityresult.launcher.launchForResult
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import li.songe.gkd.data.TransferData
import li.songe.gkd.data.Value import li.songe.gkd.data.Value
import li.songe.gkd.data.deleteSubscription 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.db.DbSet
import li.songe.gkd.ui.component.SubsItemCard import li.songe.gkd.ui.component.SubsItemCard
import li.songe.gkd.ui.component.getDialogResult 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.LOCAL_SUBS_ID
import li.songe.gkd.util.LocalLauncher import li.songe.gkd.util.LocalLauncher
import li.songe.gkd.util.checkSubsUpdate import li.songe.gkd.util.checkSubsUpdate
import li.songe.gkd.util.isSafeUrl import li.songe.gkd.util.isSafeUrl
import li.songe.gkd.util.json
import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.launchAsFn
import li.songe.gkd.util.launchTry import li.songe.gkd.util.launchTry
import li.songe.gkd.util.readFileZipByteArray
import li.songe.gkd.util.subsIdToRawFlow import li.songe.gkd.util.subsIdToRawFlow
import li.songe.gkd.util.subsItemsFlow import li.songe.gkd.util.subsItemsFlow
import li.songe.gkd.util.subsRefreshingFlow import li.songe.gkd.util.subsRefreshingFlow
@ -225,7 +221,7 @@ fun useSubsManagePage(): ScaffoldExt {
} }
} }
IconButton(onClick = vm.viewModelScope.launchAsFn(Dispatchers.IO) { IconButton(onClick = vm.viewModelScope.launchAsFn(Dispatchers.IO) {
context.shareSubs(*selectedIds.toLongArray()) exportData(context, selectedIds)
}) { }) {
Icon( Icon(
imageVector = Icons.Default.Share, imageVector = Icons.Default.Share,
@ -305,17 +301,7 @@ fun useSubsManagePage(): ScaffoldExt {
toast("未选择文件") toast("未选择文件")
return@launchAsFn return@launchAsFn
} }
val string = readFileZipByteArray( importData(uri)
UriUtils.uri2Bytes(uri), "${TransferData.TYPE}.json"
)
if (string != null) {
val transferData =
json.decodeFromString<TransferData>(string)
importTransferData(transferData)
toast("导入成功")
} else {
toast("导入文件无数据")
}
}, },
) )
} }

View File

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