diff --git a/app/build.gradle b/app/build.gradle index 9e3e5c3..2eb9f30 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ android { applicationId "com.skyd.rays" minSdk 24 targetSdk 34 - versionCode 33 - versionName "1.6-beta03" + versionCode 34 + versionName "1.6-beta04" flavorDimensions = ["versionName"] testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/com/skyd/rays/config/StickerConfig.kt b/app/src/main/java/com/skyd/rays/config/StickerConfig.kt index 4a0cadd..17928ad 100644 --- a/app/src/main/java/com/skyd/rays/config/StickerConfig.kt +++ b/app/src/main/java/com/skyd/rays/config/StickerConfig.kt @@ -8,5 +8,8 @@ val STICKER_DIR: String = File(appContext.filesDir.path, "Sticker").path val TEMP_STICKER_DIR: File = File(appContext.cacheDir, "TempSticker") +val IMPORT_FILES_DIR: File = File(appContext.cacheDir, "ImportFiles") +val EXPORT_FILES_DIR: File = File(appContext.cacheDir, "ExportFiles") + val refreshStickerData: MutableSharedFlow = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) \ No newline at end of file diff --git a/app/src/main/java/com/skyd/rays/di/DatabaseModule.kt b/app/src/main/java/com/skyd/rays/di/DatabaseModule.kt index 0fd5d6e..64cacb3 100644 --- a/app/src/main/java/com/skyd/rays/di/DatabaseModule.kt +++ b/app/src/main/java/com/skyd/rays/di/DatabaseModule.kt @@ -4,7 +4,7 @@ import android.content.Context import com.skyd.rays.model.db.AppDatabase import com.skyd.rays.model.db.dao.ApiGrantPackageDao import com.skyd.rays.model.db.dao.SearchDomainDao -import com.skyd.rays.model.db.dao.StickerDao +import com.skyd.rays.model.db.dao.sticker.StickerDao import com.skyd.rays.model.db.dao.TagDao import com.skyd.rays.model.db.dao.UriStringSharePackageDao import dagger.Module diff --git a/app/src/main/java/com/skyd/rays/model/bean/ImportExportInfo.kt b/app/src/main/java/com/skyd/rays/model/bean/ImportExportInfo.kt new file mode 100644 index 0000000..1b475fe --- /dev/null +++ b/app/src/main/java/com/skyd/rays/model/bean/ImportExportInfo.kt @@ -0,0 +1,23 @@ +package com.skyd.rays.model.bean + +import android.net.Uri +import android.os.Parcelable +import androidx.annotation.Keep +import kotlinx.parcelize.Parcelize + +@Keep +sealed interface ImportExportInfo : BaseBean + +@Parcelize +data class ImportExportResultInfo( + var time: Long, + var count: Int, + var backupFile: Uri, +) : ImportExportInfo, Parcelable + +@Parcelize +data class ImportExportWaitingInfo( + var current: Int?, + var total: Int?, + var msg: String, +) : ImportExportInfo, Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/skyd/rays/model/bean/StickerWithTags.kt b/app/src/main/java/com/skyd/rays/model/bean/StickerWithTags.kt index 0be766f..6977c42 100644 --- a/app/src/main/java/com/skyd/rays/model/bean/StickerWithTags.kt +++ b/app/src/main/java/com/skyd/rays/model/bean/StickerWithTags.kt @@ -5,6 +5,7 @@ import androidx.room.Embedded import androidx.room.Relation import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +import java.io.File @Parcelize @Serializable @@ -16,3 +17,9 @@ data class StickerWithTags( ) val tags: List ) : Parcelable + +@Parcelize +data class StickerWithTagsAndFile( + val stickerWithTags: StickerWithTags, + val stickerFile: File +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/skyd/rays/model/db/AppDatabase.kt b/app/src/main/java/com/skyd/rays/model/db/AppDatabase.kt index ec41681..21ef82c 100644 --- a/app/src/main/java/com/skyd/rays/model/db/AppDatabase.kt +++ b/app/src/main/java/com/skyd/rays/model/db/AppDatabase.kt @@ -12,7 +12,7 @@ import com.skyd.rays.model.bean.TagBean import com.skyd.rays.model.bean.UriStringSharePackageBean import com.skyd.rays.model.db.dao.ApiGrantPackageDao import com.skyd.rays.model.db.dao.SearchDomainDao -import com.skyd.rays.model.db.dao.StickerDao +import com.skyd.rays.model.db.dao.sticker.StickerDao import com.skyd.rays.model.db.dao.TagDao import com.skyd.rays.model.db.dao.UriStringSharePackageDao import com.skyd.rays.model.db.migration.Migration1To2 diff --git a/app/src/main/java/com/skyd/rays/model/db/dao/sticker/HandleTagsProxy.kt b/app/src/main/java/com/skyd/rays/model/db/dao/sticker/HandleTagsProxy.kt new file mode 100644 index 0000000..2790924 --- /dev/null +++ b/app/src/main/java/com/skyd/rays/model/db/dao/sticker/HandleTagsProxy.kt @@ -0,0 +1,88 @@ +package com.skyd.rays.model.db.dao.sticker + +import android.os.Parcelable +import com.skyd.rays.R +import com.skyd.rays.appContext +import com.skyd.rays.config.STICKER_DIR +import com.skyd.rays.model.bean.StickerWithTags +import com.skyd.rays.model.db.dao.TagDao +import kotlinx.parcelize.Parcelize +import java.io.File +import java.util.UUID + + +@Parcelize +sealed interface HandleImportedStickerProxy : Parcelable { + fun handle( + stickerDao: StickerDao, + tagDao: TagDao, + importedStickerWithTags: StickerWithTags, + stickerFile: File, + ): Boolean + + fun checkStickerWithTagsFormat(stickerWithTags: StickerWithTags) { + check(stickerWithTags.sticker.stickerMd5.isNotBlank()) { "sticker's md5 is blank!" } + UUID.fromString(stickerWithTags.sticker.uuid) + } + + fun moveFile(stickerFile: File) { + val destStickerFile = File(File(STICKER_DIR), stickerFile.name) + stickerFile.renameTo(destStickerFile) + } + + val displayName: String + + // 冲突则跳过 + @Parcelize + object SkipProxy : HandleImportedStickerProxy { + override fun handle( + stickerDao: StickerDao, + tagDao: TagDao, + importedStickerWithTags: StickerWithTags, + stickerFile: File, + ): Boolean { + checkStickerWithTagsFormat(importedStickerWithTags) + val stickerUuid = importedStickerWithTags.sticker.uuid + // 冲突跳过 + if (stickerDao.containsByUuid(stickerUuid) != 0) { + return false + } + moveFile(stickerFile) + stickerDao.innerAddSticker(importedStickerWithTags.sticker) + importedStickerWithTags.tags.forEach { + it.stickerUuid = stickerUuid + } + tagDao.deleteTags(stickerUuid) + tagDao.addTags(importedStickerWithTags.tags) + return true + } + + override val displayName: String + get() = appContext.getString(R.string.handle_imported_sticker_proxy_skip) + } + + // 冲突则覆盖 + @Parcelize + object ReplaceProxy : HandleImportedStickerProxy { + override fun handle( + stickerDao: StickerDao, + tagDao: TagDao, + importedStickerWithTags: StickerWithTags, + stickerFile: File, + ): Boolean { + checkStickerWithTagsFormat(importedStickerWithTags) + val stickerUuid = importedStickerWithTags.sticker.uuid + moveFile(stickerFile) + stickerDao.innerAddSticker(importedStickerWithTags.sticker) + importedStickerWithTags.tags.forEach { + it.stickerUuid = stickerUuid + } + tagDao.deleteTags(stickerUuid) + tagDao.addTags(importedStickerWithTags.tags) + return true + } + + override val displayName: String + get() = appContext.getString(R.string.handle_imported_sticker_proxy_replace) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/rays/model/db/dao/StickerDao.kt b/app/src/main/java/com/skyd/rays/model/db/dao/sticker/StickerDao.kt similarity index 85% rename from app/src/main/java/com/skyd/rays/model/db/dao/StickerDao.kt rename to app/src/main/java/com/skyd/rays/model/db/dao/sticker/StickerDao.kt index a2087a3..75ce898 100644 --- a/app/src/main/java/com/skyd/rays/model/db/dao/StickerDao.kt +++ b/app/src/main/java/com/skyd/rays/model/db/dao/sticker/StickerDao.kt @@ -1,4 +1,4 @@ -package com.skyd.rays.model.db.dao +package com.skyd.rays.model.db.dao.sticker import androidx.room.* import androidx.sqlite.db.SupportSQLiteQuery @@ -13,6 +13,8 @@ import com.skyd.rays.model.bean.StickerBean.Companion.SHARE_COUNT_COLUMN import com.skyd.rays.model.bean.StickerBean.Companion.STICKER_MD5_COLUMN import com.skyd.rays.model.bean.StickerBean.Companion.UUID_COLUMN import com.skyd.rays.model.bean.StickerWithTags +import com.skyd.rays.model.bean.StickerWithTagsAndFile +import com.skyd.rays.model.db.dao.TagDao import com.skyd.rays.model.preference.CurrentStickerUuidPreference import dagger.hilt.EntryPoint import dagger.hilt.InstallIn @@ -142,12 +144,33 @@ interface StickerDao { fun innerDeleteSticker(stickerUuid: String): Int @Transaction - fun webDavImportData(stickerWithTagsList: List) { + fun importDataFromExternal(stickerWithTagsList: List) { + // 原始方案就是覆盖 stickerWithTagsList.forEach { addStickerWithTags(stickerWithTags = it, updateModifyTime = false) } } + @Transaction + fun importDataFromExternal( + stickerWithTagsList: List, + proxy: HandleImportedStickerProxy, + ): Int { + val hiltEntryPoint = EntryPointAccessors + .fromApplication(appContext, StickerDaoEntryPoint::class.java) + var updatedCount = 0 + stickerWithTagsList.forEach { + val updated = proxy.handle( + stickerDao = this, + tagDao = hiltEntryPoint.tagDao, + importedStickerWithTags = it.stickerWithTags, + stickerFile = it.stickerFile, + ) + if (updated) updatedCount++ + } + return updatedCount + } + @Transaction @Query("DELETE FROM $STICKER_TABLE_NAME") fun innerDeleteAllStickers() diff --git a/app/src/main/java/com/skyd/rays/model/respository/AddRepository.kt b/app/src/main/java/com/skyd/rays/model/respository/AddRepository.kt index 44bfade..d1f53e4 100644 --- a/app/src/main/java/com/skyd/rays/model/respository/AddRepository.kt +++ b/app/src/main/java/com/skyd/rays/model/respository/AddRepository.kt @@ -21,7 +21,7 @@ import com.skyd.rays.ext.dataStore import com.skyd.rays.ext.get import com.skyd.rays.ext.md5 import com.skyd.rays.model.bean.StickerWithTags -import com.skyd.rays.model.db.dao.StickerDao +import com.skyd.rays.model.db.dao.sticker.StickerDao import com.skyd.rays.model.preference.StickerClassificationModelPreference import com.skyd.rays.model.preference.ai.ClassificationThresholdPreference import com.skyd.rays.model.preference.ai.TextRecognizeThresholdPreference diff --git a/app/src/main/java/com/skyd/rays/model/respository/DataRepository.kt b/app/src/main/java/com/skyd/rays/model/respository/DataRepository.kt index 06e35f4..69fa0fe 100644 --- a/app/src/main/java/com/skyd/rays/model/respository/DataRepository.kt +++ b/app/src/main/java/com/skyd/rays/model/respository/DataRepository.kt @@ -2,7 +2,7 @@ package com.skyd.rays.model.respository import com.skyd.rays.base.BaseData import com.skyd.rays.base.BaseRepository -import com.skyd.rays.model.db.dao.StickerDao +import com.skyd.rays.model.db.dao.sticker.StickerDao import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import javax.inject.Inject diff --git a/app/src/main/java/com/skyd/rays/model/respository/HomeRepository.kt b/app/src/main/java/com/skyd/rays/model/respository/HomeRepository.kt index 736a8c4..801a15d 100644 --- a/app/src/main/java/com/skyd/rays/model/respository/HomeRepository.kt +++ b/app/src/main/java/com/skyd/rays/model/respository/HomeRepository.kt @@ -14,7 +14,7 @@ import com.skyd.rays.model.bean.StickerBean import com.skyd.rays.model.bean.StickerWithTags import com.skyd.rays.model.bean.TagBean import com.skyd.rays.model.db.dao.SearchDomainDao -import com.skyd.rays.model.db.dao.StickerDao +import com.skyd.rays.model.db.dao.sticker.StickerDao import com.skyd.rays.model.preference.ExportStickerDirPreference import com.skyd.rays.model.preference.search.IntersectSearchBySpacePreference import com.skyd.rays.model.preference.search.UseRegexSearchPreference diff --git a/app/src/main/java/com/skyd/rays/model/respository/ImportExportFilesRepository.kt b/app/src/main/java/com/skyd/rays/model/respository/ImportExportFilesRepository.kt new file mode 100644 index 0000000..acab95b --- /dev/null +++ b/app/src/main/java/com/skyd/rays/model/respository/ImportExportFilesRepository.kt @@ -0,0 +1,220 @@ +package com.skyd.rays.model.respository + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import com.skyd.rays.R +import com.skyd.rays.appContext +import com.skyd.rays.base.BaseData +import com.skyd.rays.base.BaseRepository +import com.skyd.rays.config.EXPORT_FILES_DIR +import com.skyd.rays.config.IMPORT_FILES_DIR +import com.skyd.rays.model.bean.ImportExportInfo +import com.skyd.rays.model.bean.ImportExportResultInfo +import com.skyd.rays.model.bean.ImportExportWaitingInfo +import com.skyd.rays.model.bean.StickerWithTags +import com.skyd.rays.model.bean.StickerWithTagsAndFile +import com.skyd.rays.model.db.dao.sticker.HandleImportedStickerProxy +import com.skyd.rays.model.db.dao.sticker.StickerDao +import com.skyd.rays.util.image.ImageFormatChecker +import com.skyd.rays.util.image.format.ImageFormat +import com.skyd.rays.util.stickerUuidToFile +import com.skyd.rays.util.unzip +import com.skyd.rays.util.zip +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.flow +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import okio.use +import java.io.File +import javax.inject.Inject +import kotlin.random.Random + + +class ImportExportFilesRepository @Inject constructor( + private val stickerDao: StickerDao, + private val json: Json +) : BaseRepository() { + companion object { + const val BACKUP_DATA_DIR = "BackupData/" + const val BACKUP_STICKER_DIR = "BackupSticker/" + } + + suspend fun requestImport( + backupFileUri: Uri, + handleImportedStickerProxy: HandleImportedStickerProxy, + ): Flow> { + return flow { + val startTime = System.currentTimeMillis() + + // 清空导入所需的临时目录 + IMPORT_FILES_DIR.deleteRecursively() + + // 解压文件 + unzip( + context = appContext, + zipFile = backupFileUri, + location = IMPORT_FILES_DIR, + onEach = { index, file -> + emitProgressData( + current = index, + msg = appContext.getString( + R.string.import_files_screen_progress_unzipping, file.toString() + ), + ) + } + ) + + // 检查文件格式 + emitProgressData( + msg = appContext.getString(R.string.import_files_screen_progress_checking_backup_format), + ) + val stickerWithTagsAndFileList = checkBackupUnzipFiles(IMPORT_FILES_DIR) + + // 移动表情包文件并保存信息到数据库 + emitProgressData( + msg = appContext.getString(R.string.import_files_screen_progress_saving_data), + ) + val updatedCount = stickerDao.importDataFromExternal( + stickerWithTagsList = stickerWithTagsAndFileList, + proxy = handleImportedStickerProxy, + ) + + // 完成操作 + emitBaseData(BaseData().apply { + code = 0 + data = ImportExportResultInfo( + time = System.currentTimeMillis() - startTime, + count = updatedCount, + backupFile = Uri.EMPTY, + ) + }) + } + } + + suspend fun requestExport(dirUri: Uri): Flow> { + return flow { + val startTime = System.currentTimeMillis() + val allStickerWithTagsList = stickerDao.getAllStickerWithTagsList() + val totalCount = allStickerWithTagsList.size + var currentCount = 0 + EXPORT_FILES_DIR.deleteRecursively() + allStickerWithTagsList.forEach { + stickerWithTagsToJsonFile(it) + stickerUuidToFile(it.sticker.uuid) + .copyTo(File("$EXPORT_FILES_DIR/$BACKUP_STICKER_DIR", it.sticker.uuid)) + emitProgressData( + current = ++currentCount, + total = totalCount, + msg = appContext.getString(R.string.export_files_screen_progress_exporting), + ) + } + val documentFile = DocumentFile.fromTreeUri(appContext, dirUri)!! + val zipFileUri: Uri = documentFile.createFile( + "application/zip", + "Rays_Backup_${Random.nextInt(0, Int.MAX_VALUE)}" + )?.uri!! + zip( + context = appContext, + zipFile = zipFileUri, + files = EXPORT_FILES_DIR.listFiles().orEmpty().toList(), + onEach = { index, file -> + emitProgressData( + current = index, + msg = appContext.getString( + R.string.export_files_screen_progress_zipping, + file.toString() + ), + ) + }, + ) + emitBaseData(BaseData().apply { + code = 0 + data = ImportExportResultInfo( + time = System.currentTimeMillis() - startTime, + count = totalCount, + backupFile = zipFileUri, + ) + }) + } + } + + private fun stickerWithTagsToJsonFile(stickerWithTags: StickerWithTags): File { + val file = File("$EXPORT_FILES_DIR/$BACKUP_DATA_DIR", stickerWithTags.sticker.uuid) + if (!file.exists()) { + if (file.parentFile?.exists() == false) { + file.parentFile?.mkdirs() + } + file.createNewFile() + } + file.printWriter().use { out -> + out.println(json.encodeToString(stickerWithTags)) + } + return file + } + + // 检查解压出来的备份文件的格式 + private fun checkBackupUnzipFiles(destDir: File): List { + val dataDir = File(destDir, BACKUP_DATA_DIR) + val stickerDir = File(destDir, BACKUP_STICKER_DIR) + check(dataDir.exists()) { "BackupData directory not exists!" } + check(stickerDir.exists()) { "BackupSticker directory not exists!" } + + val dataList = dataDir.list().orEmpty() + val stickersList = stickerDir.list().orEmpty() + check(dataList.contentEquals(stickersList)) { + "The contents of the BackupData directory do not match the contents of the BackupSticker directory!" + } + + val dataListFiles = dataDir.listFiles().orEmpty() + val stickersListFiles = stickerDir.listFiles().orEmpty() + // 排序,确保文件名相同的在同一个数组的位置 + dataListFiles.sortBy { it!!.name } + stickersListFiles.sortBy { it!!.name } + // 最终的数据 List + val stickerWithTagsAndFileList = mutableListOf() + dataListFiles.forEachIndexed { index, file -> + // 反序列化 Json 数据 + var stickerWithTags: StickerWithTags? = null + file!!.inputStream().use { inputStream -> + stickerWithTags = json.decodeFromStream(inputStream) + } + + // 检查 Json 里面存的表情包的 UUID 与文件名是否相同 + val stickerUuid = stickerWithTags!!.sticker.uuid + check(file.name == stickerUuid) { + "BackupData json file name: '${file.name}' do not match sticker's uuid: '$stickerUuid'" + } + + // 检查两者的文件名是否相同 + check(stickersListFiles[index].name == file.name) { + "The name of BackupData json file: '${file.name}' do not match the name of the BackupSticker file: '${stickersListFiles[index].name}'" + } + + // 检查表情包的图片格式 + stickersListFiles[index].inputStream().use { inputStream -> + check(ImageFormatChecker.check(inputStream) != ImageFormat.UNDEFINED) { + "Unsupported sticker format, sticker file name: '${stickersListFiles[index].name}'" + } + stickerWithTagsAndFileList += StickerWithTagsAndFile( + stickerWithTags = stickerWithTags!!, + stickerFile = stickersListFiles[index] + ) + } + } + + return stickerWithTagsAndFileList + } + + private suspend fun FlowCollector>.emitProgressData( + current: Int? = null, + total: Int? = null, + msg: String + ) { + emitBaseData(BaseData().apply { + code = 0 + data = ImportExportWaitingInfo(current = current, total = total, msg = msg) + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/rays/model/respository/WebDavRepository.kt b/app/src/main/java/com/skyd/rays/model/respository/WebDavRepository.kt index 35249bf..d8d6249 100644 --- a/app/src/main/java/com/skyd/rays/model/respository/WebDavRepository.kt +++ b/app/src/main/java/com/skyd/rays/model/respository/WebDavRepository.kt @@ -10,7 +10,7 @@ import com.skyd.rays.model.bean.StickerWithTags import com.skyd.rays.model.bean.WebDavInfo import com.skyd.rays.model.bean.WebDavResultInfo import com.skyd.rays.model.bean.WebDavWaitingInfo -import com.skyd.rays.model.db.dao.StickerDao +import com.skyd.rays.model.db.dao.sticker.StickerDao import com.skyd.rays.util.md5 import com.skyd.rays.util.stickerUuidToFile import com.thegrizzlylabs.sardineandroid.Sardine @@ -146,7 +146,7 @@ class WebDavRepository @Inject constructor( waitToAddList += json.decodeFromStream(inputStream) } if (waitToAddList.size > 10) { - stickerDao.webDavImportData(waitToAddList) + stickerDao.importDataFromExternal(waitToAddList) waitToAddList.clear() } emitProgressData( @@ -165,7 +165,7 @@ class WebDavRepository @Inject constructor( inputStream.saveTo(stickerUuidToFile(entry.value.uuid)) } if (waitToAddList.size > 10) { - stickerDao.webDavImportData(waitToAddList) + stickerDao.importDataFromExternal(waitToAddList) waitToAddList.clear() } emitProgressData( @@ -174,7 +174,7 @@ class WebDavRepository @Inject constructor( msg = appContext.getString(R.string.webdav_screen_progress_download_data_sticker), ) } - stickerDao.webDavImportData(waitToAddList) + stickerDao.importDataFromExternal(waitToAddList) emitBaseData(BaseData().apply { code = 0 data = WebDavResultInfo( diff --git a/app/src/main/java/com/skyd/rays/ui/activity/MainActivity.kt b/app/src/main/java/com/skyd/rays/ui/activity/MainActivity.kt index aa244ba..4f093f0 100644 --- a/app/src/main/java/com/skyd/rays/ui/activity/MainActivity.kt +++ b/app/src/main/java/com/skyd/rays/ui/activity/MainActivity.kt @@ -64,6 +64,10 @@ import com.skyd.rays.ui.screen.settings.data.importexport.IMPORT_EXPORT_SCREEN_R import com.skyd.rays.ui.screen.settings.data.importexport.ImportExportScreen import com.skyd.rays.ui.screen.settings.data.importexport.cloud.webdav.WEBDAV_SCREEN_ROUTE import com.skyd.rays.ui.screen.settings.data.importexport.cloud.webdav.WebDavScreen +import com.skyd.rays.ui.screen.settings.data.importexport.file.exportfiles.EXPORT_FILES_SCREEN_ROUTE +import com.skyd.rays.ui.screen.settings.data.importexport.file.exportfiles.ExportFilesScreen +import com.skyd.rays.ui.screen.settings.data.importexport.file.importfiles.IMPORT_FILES_SCREEN_ROUTE +import com.skyd.rays.ui.screen.settings.data.importexport.file.importfiles.ImportFilesScreen import com.skyd.rays.ui.screen.settings.ml.ML_SCREEN_ROUTE import com.skyd.rays.ui.screen.settings.ml.MlScreen import com.skyd.rays.ui.screen.settings.ml.classification.CLASSIFICATION_SCREEN_ROUTE @@ -200,6 +204,12 @@ class MainActivity : AppCompatActivity() { composable(route = WEBDAV_SCREEN_ROUTE) { WebDavScreen() } + composable(route = EXPORT_FILES_SCREEN_ROUTE) { + ExportFilesScreen() + } + composable(route = IMPORT_FILES_SCREEN_ROUTE) { + ImportFilesScreen() + } composable(route = DATA_SCREEN_ROUTE) { DataScreen() } diff --git a/app/src/main/java/com/skyd/rays/ui/screen/add/AddScreen.kt b/app/src/main/java/com/skyd/rays/ui/screen/add/AddScreen.kt index d589188..061b5af 100644 --- a/app/src/main/java/com/skyd/rays/ui/screen/add/AddScreen.kt +++ b/app/src/main/java/com/skyd/rays/ui/screen/add/AddScreen.kt @@ -148,10 +148,6 @@ fun AddScreen( } } - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { @@ -328,6 +324,10 @@ fun AddScreen( ) } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + // 添加/修改完成后重设页面数据 fun resetStickerData() { titleText = "" diff --git a/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/ImportExportScreen.kt b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/ImportExportScreen.kt index d60ec65..90ee513 100644 --- a/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/ImportExportScreen.kt +++ b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/ImportExportScreen.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CloudSync +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Upload import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults @@ -19,6 +21,8 @@ import com.skyd.rays.ui.component.RaysTopBar import com.skyd.rays.ui.component.RaysTopBarStyle import com.skyd.rays.ui.local.LocalNavController import com.skyd.rays.ui.screen.settings.data.importexport.cloud.webdav.WEBDAV_SCREEN_ROUTE +import com.skyd.rays.ui.screen.settings.data.importexport.file.exportfiles.EXPORT_FILES_SCREEN_ROUTE +import com.skyd.rays.ui.screen.settings.data.importexport.file.importfiles.IMPORT_FILES_SCREEN_ROUTE const val IMPORT_EXPORT_SCREEN_ROUTE = "importExportScreen" @@ -55,6 +59,27 @@ fun ImportExportScreen() { onClick = { navController.navigate(WEBDAV_SCREEN_ROUTE) } ) } + item { + CategorySettingsItem( + text = stringResource(id = R.string.import_export_screen_using_file_category) + ) + } + item { + BaseSettingsItem( + icon = rememberVectorPainter(image = Icons.Default.Download), + text = stringResource(id = R.string.import_files_screen_name), + descriptionText = stringResource(id = R.string.import_files_screen_description), + onClick = { navController.navigate(IMPORT_FILES_SCREEN_ROUTE) } + ) + } + item { + BaseSettingsItem( + icon = rememberVectorPainter(image = Icons.Default.Upload), + text = stringResource(id = R.string.export_files_screen_name), + descriptionText = stringResource(id = R.string.export_files_screen_description), + onClick = { navController.navigate(EXPORT_FILES_SCREEN_ROUTE) } + ) + } } } } diff --git a/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/cloud/webdav/WebDavScreen.kt b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/cloud/webdav/WebDavScreen.kt index 3b54bd2..7b38ca6 100644 --- a/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/cloud/webdav/WebDavScreen.kt +++ b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/cloud/webdav/WebDavScreen.kt @@ -283,7 +283,7 @@ fun WebDavScreen(viewModel: WebDavViewModel = hiltViewModel()) { visible = openWaitingDialog, currentValue = waitingDialogData?.current, totalValue = waitingDialogData?.total, - msg = waitingDialogData?.msg + "\n\n" + stringResource(id = R.string.webdav_screen_warning), + msg = waitingDialogData?.msg + "\n\n" + stringResource(id = R.string.data_sync_warning), ) DeleteWarningDialog( visible = openDeleteWarningDialog != null, @@ -316,7 +316,7 @@ fun WebDavScreen(viewModel: WebDavViewModel = hiltViewModel()) { Text(text = stringResource(id = R.string.dialog_warning)) }, text = { - Text(text = stringResource(id = R.string.webdav_screen_warning)) + Text(text = stringResource(id = R.string.data_sync_warning)) }, confirmButton = { TextButton(onClick = { openWarningDialog = false }) { diff --git a/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/exportfiles/ExportFilesEvent.kt b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/exportfiles/ExportFilesEvent.kt new file mode 100644 index 0000000..856066d --- /dev/null +++ b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/exportfiles/ExportFilesEvent.kt @@ -0,0 +1,12 @@ +package com.skyd.rays.ui.screen.settings.data.importexport.file.exportfiles + +import com.skyd.rays.base.IUiEvent +import com.skyd.rays.model.bean.ImportExportInfo + +class ExportFilesEvent( + val exportResultUiEvent: ExportResultUiEvent? = null, +) : IUiEvent + +sealed class ExportResultUiEvent { + class Success(val info: ImportExportInfo) : ExportResultUiEvent() +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/exportfiles/ExportFilesIntent.kt b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/exportfiles/ExportFilesIntent.kt new file mode 100644 index 0000000..e22d802 --- /dev/null +++ b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/exportfiles/ExportFilesIntent.kt @@ -0,0 +1,8 @@ +package com.skyd.rays.ui.screen.settings.data.importexport.file.exportfiles + +import android.net.Uri +import com.skyd.rays.base.IUiIntent + +sealed class ExportFilesIntent : IUiIntent { + data class Export(val dirUri: Uri) : ExportFilesIntent() +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/exportfiles/ExportFilesScreen.kt b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/exportfiles/ExportFilesScreen.kt new file mode 100644 index 0000000..a09a074 --- /dev/null +++ b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/exportfiles/ExportFilesScreen.kt @@ -0,0 +1,200 @@ +package com.skyd.rays.ui.screen.settings.data.importexport.file.exportfiles + +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.skyd.rays.R +import com.skyd.rays.appContext +import com.skyd.rays.base.LoadUiIntent +import com.skyd.rays.ext.inBottomOrNotLarge +import com.skyd.rays.ext.showSnackbarWithLaunchedEffect +import com.skyd.rays.model.bean.ImportExportResultInfo +import com.skyd.rays.model.bean.ImportExportWaitingInfo +import com.skyd.rays.ui.component.BaseSettingsItem +import com.skyd.rays.ui.component.BottomHideExtendedFloatingActionButton +import com.skyd.rays.ui.component.RaysTopBar +import com.skyd.rays.ui.component.RaysTopBarStyle +import com.skyd.rays.ui.component.dialog.RaysDialog +import com.skyd.rays.ui.component.dialog.WaitingDialog + + +const val EXPORT_FILES_SCREEN_ROUTE = "exportFilesScreen" + +@Composable +fun ExportFilesScreen(viewModel: ExportFilesViewModel = hiltViewModel()) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + val uiEvent by viewModel.uiEventFlow.collectAsStateWithLifecycle(initialValue = null) + val loadUiIntent by viewModel.loadUiIntentFlow.collectAsStateWithLifecycle(initialValue = null) + var openExportDialog by rememberSaveable { mutableStateOf(null) } + var openWaitingDialog by rememberSaveable { mutableStateOf(false) } + var waitingDialogData by rememberSaveable { mutableStateOf(null) } + + val pickExportedFileLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { } + + var exportDir by rememberSaveable { mutableStateOf(Uri.EMPTY) } + val pickExportDirLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocumentTree() + ) { uri -> + if (uri != null) { + exportDir = uri + } + } + val lazyListState = rememberLazyListState() + val fabVisibility by remember { + derivedStateOf { + lazyListState.inBottomOrNotLarge && exportDir.toString().isNotBlank() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + topBar = { + RaysTopBar( + style = RaysTopBarStyle.Large, + scrollBehavior = scrollBehavior, + title = { Text(text = stringResource(R.string.export_files_screen_name)) }, + ) + }, + floatingActionButton = { + BottomHideExtendedFloatingActionButton( + visible = fabVisibility, + text = { Text(text = stringResource(R.string.export_files_screen_export)) }, + icon = { Icon(imageVector = Icons.Default.Done, contentDescription = null) }, + onClick = { viewModel.sendUiIntent(ExportFilesIntent.Export(exportDir)) }, + contentDescription = stringResource(R.string.export_files_screen_export) + ) + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + contentPadding = paddingValues, + state = lazyListState, + ) { + item { + BaseSettingsItem( + icon = rememberVectorPainter(image = Icons.Default.Folder), + text = stringResource(id = R.string.export_files_screen_select_dir), + descriptionText = exportDir.toString().ifBlank { null }, + onClick = { pickExportDirLauncher.launch(exportDir) } + ) + } + } + } + + WaitingDialog( + visible = openWaitingDialog, + currentValue = waitingDialogData?.current, + totalValue = waitingDialogData?.total, + msg = waitingDialogData?.msg + "\n\n" + stringResource(id = R.string.data_sync_warning), + ) + + RaysDialog( + visible = openExportDialog != null, + title = { Text(text = stringResource(id = R.string.export_files_screen_successful)) }, + text = { + openExportDialog?.let { info -> + Text( + text = context.resources.getQuantityString( + R.plurals.export_files_screen_successful_desc, + info.count, + info.count, + info.time / 1000f, + ) + ) + } + }, + onDismissRequest = { openExportDialog = null }, + confirmButton = { + TextButton(onClick = { + val intent = Intent.createChooser( + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(openExportDialog!!.backupFile, "application/zip") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }, + context.getString(R.string.export_files_screen_open_backup_file) + ) + pickExportedFileLauncher.launch(intent) + }) { + Text(text = stringResource(id = R.string.export_files_screen_progress_open_backup_file)) + } + }, + dismissButton = { + TextButton(onClick = { openExportDialog = null }) { + Text(text = stringResource(id = R.string.dialog_cancel)) + } + } + ) + + when (val loadUi = loadUiIntent) { + is LoadUiIntent.Error -> { + snackbarHostState.showSnackbarWithLaunchedEffect( + message = appContext.getString(R.string.export_files_screen_failed, loadUi.msg), + key2 = loadUiIntent, + ) + openWaitingDialog = false + waitingDialogData = null + } + + is LoadUiIntent.Loading -> { + openWaitingDialog = loadUi.isShow + if (!openWaitingDialog) { + waitingDialogData = null + } + } + + null -> Unit + } + + uiEvent?.apply { + when (exportResultUiEvent) { + is ExportResultUiEvent.Success -> { + when (val result = exportResultUiEvent.info) { + is ImportExportResultInfo -> { + LaunchedEffect(this) { openExportDialog = result } + } + + is ImportExportWaitingInfo -> { + waitingDialogData = result + } + } + } + + null -> Unit + } + } +} diff --git a/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/exportfiles/ExportFilesViewModel.kt b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/exportfiles/ExportFilesViewModel.kt new file mode 100644 index 0000000..8eff20d --- /dev/null +++ b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/exportfiles/ExportFilesViewModel.kt @@ -0,0 +1,31 @@ +package com.skyd.rays.ui.screen.settings.data.importexport.file.exportfiles + +import com.skyd.rays.base.BaseViewModel +import com.skyd.rays.base.IUIChange +import com.skyd.rays.base.IUiState +import com.skyd.rays.model.respository.ImportExportFilesRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.merge +import javax.inject.Inject + +@HiltViewModel +class ExportFilesViewModel @Inject constructor( + private var importExportFilesRepo: ImportExportFilesRepository +) : BaseViewModel() { + override fun initUiState(): IUiState { + return object : IUiState {} + } + + override fun IUIChange.checkStateOrEvent() = this as? IUiState to this as? ExportFilesEvent + + override fun Flow.handleIntent(): Flow = merge( + doIsInstance { intent -> + importExportFilesRepo.requestExport(intent.dirUri) + .mapToUIChange { data -> + ExportFilesEvent(exportResultUiEvent = ExportResultUiEvent.Success(data)) + } + .defaultFinally() + }, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/importfiles/ImportFilesEvent.kt b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/importfiles/ImportFilesEvent.kt new file mode 100644 index 0000000..5608a26 --- /dev/null +++ b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/importfiles/ImportFilesEvent.kt @@ -0,0 +1,12 @@ +package com.skyd.rays.ui.screen.settings.data.importexport.file.importfiles + +import com.skyd.rays.base.IUiEvent +import com.skyd.rays.model.bean.ImportExportInfo + +class ImportFilesEvent( + val importResultUiEvent: ImportResultUiEvent? = null, +) : IUiEvent + +sealed class ImportResultUiEvent { + class Success(val info: ImportExportInfo) : ImportResultUiEvent() +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/importfiles/ImportFilesIntent.kt b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/importfiles/ImportFilesIntent.kt new file mode 100644 index 0000000..e240d3a --- /dev/null +++ b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/importfiles/ImportFilesIntent.kt @@ -0,0 +1,12 @@ +package com.skyd.rays.ui.screen.settings.data.importexport.file.importfiles + +import android.net.Uri +import com.skyd.rays.base.IUiIntent +import com.skyd.rays.model.db.dao.sticker.HandleImportedStickerProxy + +sealed class ImportFilesIntent : IUiIntent { + data class Import( + val backupFileUri: Uri, + val proxy: HandleImportedStickerProxy, + ) : ImportFilesIntent() +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/importfiles/ImportFilesScreen.kt b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/importfiles/ImportFilesScreen.kt new file mode 100644 index 0000000..2317cbb --- /dev/null +++ b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/importfiles/ImportFilesScreen.kt @@ -0,0 +1,226 @@ +package com.skyd.rays.ui.screen.settings.data.importexport.file.importfiles + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.FolderZip +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.skyd.rays.R +import com.skyd.rays.appContext +import com.skyd.rays.base.LoadUiIntent +import com.skyd.rays.ext.inBottomOrNotLarge +import com.skyd.rays.ext.showSnackbarWithLaunchedEffect +import com.skyd.rays.model.bean.ImportExportResultInfo +import com.skyd.rays.model.bean.ImportExportWaitingInfo +import com.skyd.rays.model.db.dao.sticker.HandleImportedStickerProxy +import com.skyd.rays.ui.component.BaseSettingsItem +import com.skyd.rays.ui.component.BottomHideExtendedFloatingActionButton +import com.skyd.rays.ui.component.RaysTopBar +import com.skyd.rays.ui.component.RaysTopBarStyle +import com.skyd.rays.ui.component.dialog.RaysDialog +import com.skyd.rays.ui.component.dialog.WaitingDialog + +const val IMPORT_FILES_SCREEN_ROUTE = "importFilesScreen" + +@Composable +fun ImportFilesScreen(viewModel: ImportFilesViewModel = hiltViewModel()) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + val uiEvent by viewModel.uiEventFlow.collectAsStateWithLifecycle(initialValue = null) + val loadUiIntent by viewModel.loadUiIntentFlow.collectAsStateWithLifecycle(initialValue = null) + var openImportDialog by rememberSaveable { mutableStateOf(null) } + var openWaitingDialog by rememberSaveable { mutableStateOf(false) } + var waitingDialogData by rememberSaveable { mutableStateOf(null) } + val importedStickerProxyList = rememberSaveable { + listOf(HandleImportedStickerProxy.SkipProxy, HandleImportedStickerProxy.ReplaceProxy) + } + var selectedImportedStickerProxyIndex by rememberSaveable { mutableIntStateOf(0) } + + var fileUri by rememberSaveable { mutableStateOf(Uri.EMPTY) } + val pickFileLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri -> + if (uri != null) { + fileUri = uri + } + } + val lazyListState = rememberLazyListState() + val fabVisibility by remember { + derivedStateOf { + lazyListState.inBottomOrNotLarge && fileUri.toString().isNotBlank() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + topBar = { + RaysTopBar( + style = RaysTopBarStyle.Large, + scrollBehavior = scrollBehavior, + title = { Text(text = stringResource(R.string.import_files_screen_name)) }, + ) + }, + floatingActionButton = { + BottomHideExtendedFloatingActionButton( + visible = fabVisibility, + text = { Text(text = stringResource(R.string.import_files_screen_import)) }, + icon = { Icon(imageVector = Icons.Default.Done, contentDescription = null) }, + onClick = { + viewModel.sendUiIntent( + ImportFilesIntent.Import( + backupFileUri = fileUri, + proxy = importedStickerProxyList[selectedImportedStickerProxyIndex], + ) + ) + }, + contentDescription = stringResource(R.string.import_files_screen_import) + ) + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + contentPadding = paddingValues, + state = lazyListState, + ) { + item { + BaseSettingsItem( + icon = rememberVectorPainter(image = Icons.Default.FolderZip), + text = stringResource(id = R.string.import_files_screen_select_file), + descriptionText = fileUri.toString().ifBlank { null }, + onClick = { pickFileLauncher.launch("application/zip") } + ) + } + item { + BaseSettingsItem( + icon = rememberVectorPainter(image = Icons.AutoMirrored.Filled.Help), + text = stringResource(R.string.import_files_screen_on_conflict), + description = { + SingleChoiceSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + ) { + importedStickerProxyList.forEachIndexed { index, proxy -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = importedStickerProxyList.size + ), + onClick = { selectedImportedStickerProxyIndex = index }, + selected = index == selectedImportedStickerProxyIndex + ) { + Text(text = proxy.displayName) + } + } + } + } + ) + } + } + } + + WaitingDialog( + visible = openWaitingDialog, + currentValue = waitingDialogData?.current, + totalValue = waitingDialogData?.total, + msg = waitingDialogData?.msg + "\n\n" + stringResource(id = R.string.data_sync_warning), + ) + + RaysDialog( + visible = openImportDialog != null, + title = { Text(text = stringResource(id = R.string.import_files_screen_successful)) }, + text = { + openImportDialog?.let { info -> + Text( + text = context.resources.getQuantityString( + R.plurals.import_files_screen_successful_desc, + info.count, + info.count, + info.time / 1000f, + ) + ) + } + }, + onDismissRequest = { openImportDialog = null }, + confirmButton = { + TextButton(onClick = { openImportDialog = null }) { + Text(text = stringResource(id = R.string.dialog_ok)) + } + } + ) + + when (val loadUi = loadUiIntent) { + is LoadUiIntent.Error -> { + snackbarHostState.showSnackbarWithLaunchedEffect( + message = appContext.getString(R.string.import_files_screen_failed, loadUi.msg), + key2 = loadUiIntent, + ) + openWaitingDialog = false + waitingDialogData = null + } + + is LoadUiIntent.Loading -> { + openWaitingDialog = loadUi.isShow + if (!openWaitingDialog) { + waitingDialogData = null + } + } + + null -> Unit + } + + uiEvent?.apply { + when (importResultUiEvent) { + is ImportResultUiEvent.Success -> { + when (val result = importResultUiEvent.info) { + is ImportExportResultInfo -> { + LaunchedEffect(this) { openImportDialog = result } + } + + is ImportExportWaitingInfo -> { + waitingDialogData = result + } + } + } + + null -> Unit + } + } +} diff --git a/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/importfiles/ImportFilesViewModel.kt b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/importfiles/ImportFilesViewModel.kt new file mode 100644 index 0000000..bea151d --- /dev/null +++ b/app/src/main/java/com/skyd/rays/ui/screen/settings/data/importexport/file/importfiles/ImportFilesViewModel.kt @@ -0,0 +1,34 @@ +package com.skyd.rays.ui.screen.settings.data.importexport.file.importfiles + +import com.skyd.rays.base.BaseViewModel +import com.skyd.rays.base.IUIChange +import com.skyd.rays.base.IUiState +import com.skyd.rays.model.respository.ImportExportFilesRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.merge +import javax.inject.Inject + +@HiltViewModel +class ImportFilesViewModel @Inject constructor( + private var importExportFilesRepo: ImportExportFilesRepository +) : BaseViewModel() { + override fun initUiState(): IUiState { + return object : IUiState {} + } + + override fun IUIChange.checkStateOrEvent() = this as? IUiState to this as? ImportFilesEvent + + override fun Flow.handleIntent(): Flow = merge( + doIsInstance { intent -> + importExportFilesRepo.requestImport( + intent.backupFileUri, + intent.proxy + ) + .mapToUIChange { data -> + ImportFilesEvent(importResultUiEvent = ImportResultUiEvent.Success(data)) + } + .defaultFinally() + }, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/rays/util/ZipUtil.kt b/app/src/main/java/com/skyd/rays/util/ZipUtil.kt new file mode 100644 index 0000000..770ec37 --- /dev/null +++ b/app/src/main/java/com/skyd/rays/util/ZipUtil.kt @@ -0,0 +1,122 @@ +package com.skyd.rays.util + +import android.content.Context +import android.net.Uri +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + + +private const val MODE_WRITE = "w" +private const val MODE_READ = "r" + +suspend fun zip(zipFile: File, files: List, onEach: (suspend (Int, File) -> Unit)? = null) { + ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { outStream -> + zip(outStream = outStream, files = files, onEach = onEach) + } +} + +suspend fun zip( + context: Context, + zipFile: Uri, + files: List, + onEach: (suspend (Int, File) -> Unit)? = null +) { + context.contentResolver.openFileDescriptor(zipFile, MODE_WRITE).use { descriptor -> + descriptor?.fileDescriptor?.let { + ZipOutputStream(BufferedOutputStream(FileOutputStream(it))).use { outStream -> + zip(outStream = outStream, files = files, onEach = onEach) + } + } + } +} + +private suspend fun zip( + outStream: ZipOutputStream, + files: List, + onEach: (suspend (Int, File) -> Unit)? = null +) { + var index = 0 + files.forEach { file -> + file.walkTopDown().forEach { f -> + val zipFileName = f.absolutePath + .removePrefix(file.parentFile?.absolutePath.orEmpty()) + .removePrefix("/") + val entry = ZipEntry("$zipFileName${(if (f.isDirectory) "/" else "")}") + outStream.putNextEntry(entry) + if (f.isFile) { + BufferedInputStream(FileInputStream(f)).use { inStream -> + inStream.copyTo(outStream) + } + onEach?.invoke(++index, file) + } + } + } +} + +suspend fun unzip( + zipFile: File, + location: File, + onEach: (suspend (Int, File) -> Unit)? = null +) { + ZipInputStream(BufferedInputStream(FileInputStream(zipFile))).use { inStream -> + unzip(inStream, location, onEach) + } +} + +suspend fun unzip( + context: Context, + zipFile: Uri, location: File, + onEach: (suspend (Int, File) -> Unit)? = null +) { + context.contentResolver.openFileDescriptor(zipFile, MODE_READ).use { descriptor -> + descriptor?.fileDescriptor?.let { + ZipInputStream(BufferedInputStream(FileInputStream(it))).use { inStream -> + unzip(inStream, location, onEach) + } + } + } +} + +private suspend fun unzip( + inStream: ZipInputStream, + location: File, + onEach: (suspend (Int, File) -> Unit)? = null +) { + if (location.exists() && !location.isDirectory) + throw IllegalStateException("Location file must be directory or not exist") + + if (!location.isDirectory) location.mkdirs() + + val locationPath = location.absolutePath.let { + if (!it.endsWith(File.separator)) "$it${File.separator}" + else it + } + + var zipEntry: ZipEntry? + var unzipFile: File + var unzipParentDir: File? + + var index = 0 + + while (inStream.nextEntry.also { zipEntry = it } != null) { + unzipFile = File(locationPath + zipEntry!!.name) + if (zipEntry!!.isDirectory) { + if (!unzipFile.isDirectory) unzipFile.mkdirs() + } else { + unzipParentDir = unzipFile.parentFile + if (unzipParentDir != null && !unzipParentDir.isDirectory) { + unzipParentDir.mkdirs() + } + BufferedOutputStream(FileOutputStream(unzipFile)).use { outStream -> + inStream.copyTo(outStream) + } + onEach?.invoke(++index, unzipFile) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 27cbe37..970aaae 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -52,6 +52,35 @@ 亮色 跟随系统 通过云 + 通过文件(Beta) + 从文件导入 + 从本地文件导入表情包、标签等信息 + 选择备份文件 + 导入 + 解压中:%s + 检查备份文件格式中… + 保存数据中… + 导入成功 + + 共更新 %d 个表情包,耗时 %.2f 秒 + + 操作失败:%s + 冲突时策略 + 导出为文件 + 将表情包、标签等信息导出为文件 + 选择导出文件夹 + 导出 + 导出中… + 压缩中:%s + 打开 + 导出成功 + + 共导出 %d 个表情包,耗时 %.2f 秒 + + 操作失败:%s + 打开备份文件 + 跳过 + 替换原有 WebDAV 通过 WebDAV 在云端同步数据 WebDAV 服务 @@ -156,7 +185,7 @@ 修改时间 标签数量 标题 - 排序方式 + 排序 逆序 多选 到顶部 @@ -170,7 +199,7 @@ 更新本地数据 删除本地数据 + 表情包 更新备份信息 - 请勿在推送和拉取时强制杀死应用或断开网络,否则可能会损坏数据! + 请勿在推送和拉取时强制杀死应用或断开网络,否则可能会损坏数据! 表情包信息 UUID 表情包 MD5 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index df19fea..b5786af 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -52,6 +52,37 @@ Light mode Follow the system Via cloud + Via files (Beta) + Import from files + Import stickers, tags, and other information from the local files + Select backup file + Import + Unzipping: %s + Checking backup file format… + Saving data… + Import successful + + Updated %d sticker in total, took %.2f s + Updated %d stickers in total, took %.2f s + + Operation failed: %s + On conflict + Export to files + Export stickers, tags, and other information to the local files + Select export directory + Export + Exporting… + Zipping: %s + Open + Export successful + + Exported %d sticker in total, took %.2f s + Exported %d stickers in total, took %.2f s + + Operation failed: %s + Open backup file + Skip + Replace WebDAV Synchronize data via WebDAV Service @@ -159,7 +190,7 @@ Modify time Number of tags Title - Sort by + Sort Reverse Multi-select To the top @@ -173,7 +204,7 @@ Update local data Delete local data + sticker Update backup information - Please do not force kill the app or disconnect the network when pushing and pulling, as this may corrupt the data! + Please do not force kill the app or disconnect the network when pushing and pulling, as this may corrupt the data! Sticker info UUID Sticker MD5