diff --git a/app/src/main/kotlin/li/songe/gkd/data/TransferData.kt b/app/src/main/kotlin/li/songe/gkd/data/TransferData.kt index c485809..0df5e1c 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/TransferData.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/TransferData.kt @@ -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 = emptyList(), - val subscriptions: List = emptyList(), val subsConfigs: List = emptyList(), val categoryConfigs: List = emptyList(), ) { @@ -22,16 +36,7 @@ data class TransferData( } } -suspend fun exportTransferData(subsItemIds: Collection): 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) { + 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(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) + } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsItemCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsItemCard.kt index 475f9ee..c9b0e1e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsItemCard.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsItemCard.kt @@ -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, "分享数据文件") -} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt index 55463c1..755550a 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt @@ -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(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) - } - ) + }) } } }, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt index c94b8d0..f31bd2d 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt @@ -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(string) - importTransferData(transferData) - toast("导入成功") - } else { - toast("导入文件无数据") - } + importData(uri) }, ) } diff --git a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt index ab8b1cf..ac29737 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt @@ -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) diff --git a/app/src/main/kotlin/li/songe/gkd/util/Zip.kt b/app/src/main/kotlin/li/songe/gkd/util/Zip.kt deleted file mode 100644 index bf7e01f..0000000 --- a/app/src/main/kotlin/li/songe/gkd/util/Zip.kt +++ /dev/null @@ -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 -} \ No newline at end of file