From 599a22c39e3ea6daa5cf20e50ca44f4e3283abda Mon Sep 17 00:00:00 2001 From: mkvenompro <116007409+mkvenompro@users.noreply.github.com> Date: Thu, 17 Sep 2026 04:08:30 +0300 Subject: [PATCH] feat: dedicated media and Wi-Fi backup/restore screens - Home quick actions for media backup/restore and Wi-Fi backup/restore - Media screen lists on-device images/videos/songs in separate tabs with thumbnails generated as root and cached locally - Wi-Fi screens list saved network configs found on the device - Backed-up items keep their absolute path, restoring writes each file back to its original location --- .../com/xayah/databackup/MainActivity.kt | 16 + source/app/src/main/res/values-ar/strings.xml | 11 + source/app/src/main/res/values/strings.xml | 11 + .../xayah/core/data/repository/FilesRepo.kt | 111 +++++++ .../com/xayah/core/datastore/ConstantUtil.kt | 32 ++ .../kotlin/com/xayah/core/model/MediaScan.kt | 13 + .../core/rootservice/IRemoteRootService.aidl | 1 + .../rootservice/impl/RemoteRootServiceImpl.kt | 79 +++++ .../rootservice/service/RemoteRootService.kt | 7 + .../kotlin/com/xayah/core/ui/route/Routes.kt | 8 + .../com/xayah/feature/main/dashboard/Index.kt | 46 +++ .../dashboard/src/main/res/values/ids.xml | 4 + .../feature/main/list/MediaRestoreScreen.kt | 171 +++++++++++ .../main/list/MediaRestoreViewModel.kt | 113 +++++++ .../xayah/feature/main/list/MediaScreen.kt | 286 ++++++++++++++++++ .../xayah/feature/main/list/MediaViewModel.kt | 104 +++++++ .../feature/main/list/WifiRestoreViewModel.kt | 90 ++++++ .../com/xayah/feature/main/list/WifiScreen.kt | 206 +++++++++++++ .../xayah/feature/main/list/WifiViewModel.kt | 88 ++++++ .../main/list/src/main/res/values/ids.xml | 11 + 20 files changed, 1408 insertions(+) create mode 100644 source/core/model/src/main/kotlin/com/xayah/core/model/MediaScan.kt create mode 100644 source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/MediaRestoreScreen.kt create mode 100644 source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/MediaRestoreViewModel.kt create mode 100644 source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/MediaScreen.kt create mode 100644 source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/MediaViewModel.kt create mode 100644 source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/WifiRestoreViewModel.kt create mode 100644 source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/WifiScreen.kt create mode 100644 source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/WifiViewModel.kt diff --git a/source/app/src/main/kotlin/com/xayah/databackup/MainActivity.kt b/source/app/src/main/kotlin/com/xayah/databackup/MainActivity.kt index d91818b56d..0aa5c76aed 100644 --- a/source/app/src/main/kotlin/com/xayah/databackup/MainActivity.kt +++ b/source/app/src/main/kotlin/com/xayah/databackup/MainActivity.kt @@ -29,6 +29,10 @@ import com.xayah.feature.main.directory.PageDirectory import com.xayah.feature.main.history.HistoryRoute import com.xayah.feature.main.history.TaskDetailsRoute import com.xayah.feature.main.list.ListRoute +import com.xayah.feature.main.list.MediaRestoreRoute +import com.xayah.feature.main.list.MediaRoute +import com.xayah.feature.main.list.WifiRestoreRoute +import com.xayah.feature.main.list.WifiRoute import com.xayah.feature.main.processing.medium.backup.MediumBackupProcessingGraph import com.xayah.feature.main.processing.medium.restore.MediumRestoreProcessingGraph import com.xayah.feature.main.processing.packages.backup.PackagesBackupProcessingGraph @@ -98,6 +102,18 @@ class MainActivity : AppCompatActivity() { composable(MainRoutes.List.route) { ListRoute() } + composable(MainRoutes.MediaBackup.route) { + MediaRoute() + } + composable(MainRoutes.MediaRestore.route) { + MediaRestoreRoute() + } + composable(MainRoutes.WifiBackup.route) { + WifiRoute() + } + composable(MainRoutes.WifiRestore.route) { + WifiRestoreRoute() + } composable(MainRoutes.Details.route) { DetailsRoute() } diff --git a/source/app/src/main/res/values-ar/strings.xml b/source/app/src/main/res/values-ar/strings.xml index e6be686125..1614d320da 100644 --- a/source/app/src/main/res/values-ar/strings.xml +++ b/source/app/src/main/res/values-ar/strings.xml @@ -155,6 +155,17 @@ هذا سيحذف كل الملفات التي تم اختيارها مساحة تخزين مخصصة اختيار الملف المراد + نسخ الميديا + صور + فيديوهات + أغاني + جارٍ فحص الجهاز عن الميديا… + لا توجد ملفات + استعادة الميديا + نسخ الواي فاي + استعادة الواي فاي + جارٍ فحص الشبكات المحفوظة… + لا توجد شبكات محفوظة تحديث متوفر مثبت حذف diff --git a/source/app/src/main/res/values/strings.xml b/source/app/src/main/res/values/strings.xml index fedaadd350..c14c76d746 100644 --- a/source/app/src/main/res/values/strings.xml +++ b/source/app/src/main/res/values/strings.xml @@ -119,6 +119,17 @@ Add Custom storage Select target folder + Backup media + Images + Videos + Songs + Scanning device for media… + No files found + Restore media + Backup Wi-Fi + Restore Wi-Fi + Scanning for saved networks… + No saved networks found This will delete all the related files you selected Cannot add backup directory as media The target folder is missing. Please set it manually. diff --git a/source/core/data/src/main/kotlin/com/xayah/core/data/repository/FilesRepo.kt b/source/core/data/src/main/kotlin/com/xayah/core/data/repository/FilesRepo.kt index 7541c936ea..02c9148162 100644 --- a/source/core/data/src/main/kotlin/com/xayah/core/data/repository/FilesRepo.kt +++ b/source/core/data/src/main/kotlin/com/xayah/core/data/repository/FilesRepo.kt @@ -9,7 +9,9 @@ import com.xayah.core.hiddenapi.castTo import com.xayah.core.model.CompressionType import com.xayah.core.model.DataType import com.xayah.core.model.File +import com.xayah.core.model.MediaKind import com.xayah.core.model.OpType +import com.xayah.core.model.ScannedMediaFile import com.xayah.core.model.database.LabelFileCrossRefEntity import com.xayah.core.model.database.MediaEntity import com.xayah.core.model.database.MediaExtraInfo @@ -28,6 +30,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import javax.inject.Inject class FilesRepo @Inject constructor( @@ -41,6 +44,11 @@ class FilesRepo @Inject constructor( ) { companion object { private const val TAG = "FilesRepo" + private const val THUMBNAIL_SIZE = 256 + + private val ImageExtensions = ConstantUtil.MediaImageExtensions + private val VideoExtensions = ConstantUtil.MediaVideoExtensions + private val AudioExtensions = ConstantUtil.MediaAudioExtensions } private fun log(block: () -> String): String = block().also { LogUtil.log { TAG to it } } @@ -241,6 +249,12 @@ class FilesRepo @Inject constructor( var failure = 0 pathList.forEach { pathString -> if (pathString.isNotEmpty()) { + // Prevent adding the backup dir itself (would cause recursion). + if (pathString == context.localBackupSaveDir()) { + failure++ + log { "$pathString is backup dir, skip." } + return@forEach + } var name = PathUtil.getFileName(pathString) filesDao.query(opType = OpType.BACKUP, preserveId = 0, cloud = "", backupDir = "").forEach { if (it.name == name && it.path != pathString) name = renameDuplicateFile(name) @@ -281,6 +295,103 @@ class FilesRepo @Inject constructor( filesDao.upsert(files) } + /** + * Probe all [ConstantUtil.KnownWifiConfigs] paths (requires root) and + * return the ones that exist on this device as name-to-path pairs. + */ + suspend fun scanWifiConfigs(): List> { + val found = mutableListOf>() + ConstantUtil.KnownWifiConfigs.forEach { (name, path) -> + if (rootService.exists(path)) found.add(name to path) + } + log { "Wi-Fi scan found ${found.size} configs." } + return found + } + + /** + * Locally backed-up Wi-Fi configs (RESTORE rows named "WiFi-*"). + */ + fun getLocalRestoreWifi(): Flow> = + filesDao.queryFilesFlow(opType = OpType.RESTORE, cloud = "", backupDir = context.localBackupSaveDir()) + .map { list -> list.filter { it.name.startsWith("WiFi-") } } + .flowOn(defaultDispatcher) + + /** + * Scan internal storage (root) for image/video/audio files. + * Each result keeps its absolute path, so backing it up and later + * restoring writes it back to the exact same location. + */ + suspend fun scanMediaFiles(): List { + val found = mutableListOf() + val root = ConstantUtil.DEFAULT_PATH_PARENT + if (rootService.exists(root)) { + rootService.walkFileTree(root).forEach { parcelable -> + val path = parcelable.pathString + val name = PathUtil.getFileName(path) + if (name.isEmpty() || name.startsWith(".")) return@forEach + if ("/Android/" in path) return@forEach + val ext = name.substringAfterLast('.', "").lowercase() + val kind = when (ext) { + in ImageExtensions -> MediaKind.Images + in VideoExtensions -> MediaKind.Videos + in AudioExtensions -> MediaKind.Audio + else -> null + } ?: return@forEach + found.add(ScannedMediaFile(path = path, name = name, kind = kind)) + } + } + log { "Media scan found ${found.size} files under $root." } + return found.sortedWith(compareBy({ it.kind.ordinal }, { it.name.lowercase() })) + } + + /** + * Classify a file path as image/video/audio by extension, + * or null if it is not a media file. + */ + fun kindOfMedia(path: String): MediaKind? { + val name = PathUtil.getFileName(path) + if (name.isEmpty()) return null + return when (name.substringAfterLast('.', "").lowercase()) { + in ImageExtensions -> MediaKind.Images + in VideoExtensions -> MediaKind.Videos + in AudioExtensions -> MediaKind.Audio + else -> null + } + } + + /** + * Locally backed-up files (RESTORE rows) for the media restore screen. + */ + fun getLocalRestoreMedia(): Flow> = + filesDao.queryFilesFlow(opType = OpType.RESTORE, cloud = "", backupDir = context.localBackupSaveDir()) + + /** + * Return a cached thumbnail (JPEG) for a media file, generating it + * via root on first use (the app process cannot read storage directly). + * Returns null when no thumbnail could be produced (caller shows an icon). + */ + suspend fun getMediaThumbnail(path: String): String? { + if (kindOfMedia(path) == null) return null + val cacheFile = java.io.File(java.io.File(context.cacheDir, "media_thumbs"), "${path.hashCode()}.jpg") + if (cacheFile.exists()) return cacheFile.absolutePath + return runCatching { + cacheFile.parentFile?.mkdirs() + val generated = rootService.generateMediaThumbnail(path, cacheFile.absolutePath, THUMBNAIL_SIZE) + if (generated.isNotEmpty() && cacheFile.exists()) cacheFile.absolutePath else null + }.getOrNull() + } + + /** + * Backup exactly [pathList]: deactivate any previously selected files + * first so the backup run contains only these items, then add them + * (activated) and let the caller navigate to the backup processing. + */ + suspend fun prepareMediaBackup(pathList: List) { + val existing = filesDao.query(opType = OpType.BACKUP, blocked = false) + if (existing.isNotEmpty()) filesDao.activateByIds(existing.map { it.id }, false) + addFiles(pathList) + } + suspend fun calculateLocalFileSize(file: MediaEntity) { file.mediaInfo.displayBytes = rootService.calculateSize(file.path) filesDao.upsert(file) diff --git a/source/core/datastore/src/main/kotlin/com/xayah/core/datastore/ConstantUtil.kt b/source/core/datastore/src/main/kotlin/com/xayah/core/datastore/ConstantUtil.kt index 4b4a57557c..85c2869e60 100644 --- a/source/core/datastore/src/main/kotlin/com/xayah/core/datastore/ConstantUtil.kt +++ b/source/core/datastore/src/main/kotlin/com/xayah/core/datastore/ConstantUtil.kt @@ -31,6 +31,38 @@ object ConstantUtil { "Download" to "${DEFAULT_PATH_PARENT}/Download", ) + /** + * Media file extensions (lowercase, without dot) shared by the + * storage scanner and the thumbnail generator. + */ + val MediaImageExtensions = setOf( + "jpg", "jpeg", "png", "gif", "webp", "bmp", + "heic", "heif", "tif", "tiff", "svg", "avif", "dng" + ) + val MediaVideoExtensions = setOf( + "mp4", "mkv", "avi", "mov", "wmv", "flv", + "webm", "3gp", "ts", "m2ts", "mpg", "mpeg", "m4v" + ) + val MediaAudioExtensions = setOf( + "mp3", "wav", "flac", "aac", "ogg", "oga", + "m4a", "opus", "amr", "mid", "midi", "wma", "aiff" + ) + + /** + * Saved Wi-Fi network configs (require root). + * Paths vary by Android version, so all known locations are probed + * and only the ones that exist on the device are added. + * Display name to absolute path. + */ + val KnownWifiConfigs = listOf( + "WiFi-WifiConfigStore" to "/data/misc/apexdata/com.android.wifi/WifiConfigStore.xml", + "WiFi-WifiConfigStoreSoftAp" to "/data/misc/apexdata/com.android.wifi/WifiConfigStoreSoftAp.xml", + "WiFi-WifiConfigStore-legacy" to "/data/misc/wifi/WifiConfigStore.xml", + "WiFi-WifiConfigStoreSoftAp-legacy" to "/data/misc/wifi/WifiConfigStoreSoftAp.xml", + "WiFi-wpa_supplicant" to "/data/misc/wifi/wpa_supplicant.conf", + "WiFi-p2p_supplicant" to "/data/misc/wifi/p2p_supplicant.conf", + ) + const val DOC_LINK = "https://DataBackupOfficial.github.io" const val GITHUB_LINK = "https://github.com/XayahSuSuSu/Android-DataBackup" const val CHAT_LINK = "https://t.me/databackupchat" diff --git a/source/core/model/src/main/kotlin/com/xayah/core/model/MediaScan.kt b/source/core/model/src/main/kotlin/com/xayah/core/model/MediaScan.kt new file mode 100644 index 0000000000..fea08c217d --- /dev/null +++ b/source/core/model/src/main/kotlin/com/xayah/core/model/MediaScan.kt @@ -0,0 +1,13 @@ +package com.xayah.core.model + +enum class MediaKind { + Images, + Videos, + Audio, +} + +data class ScannedMediaFile( + val path: String, + val name: String, + val kind: MediaKind, +) diff --git a/source/core/rootservice/src/main/aidl/com/xayah/core/rootservice/IRemoteRootService.aidl b/source/core/rootservice/src/main/aidl/com/xayah/core/rootservice/IRemoteRootService.aidl index 6cf86b00b6..b89cc18dda 100644 --- a/source/core/rootservice/src/main/aidl/com/xayah/core/rootservice/IRemoteRootService.aidl +++ b/source/core/rootservice/src/main/aidl/com/xayah/core/rootservice/IRemoteRootService.aidl @@ -47,4 +47,5 @@ interface IRemoteRootService { void setOpsMode(int code, int uid, String packageName, int mode); String calculateMD5(String src); + String generateMediaThumbnail(String src, String dst, int size); } diff --git a/source/core/rootservice/src/main/kotlin/com/xayah/core/rootservice/impl/RemoteRootServiceImpl.kt b/source/core/rootservice/src/main/kotlin/com/xayah/core/rootservice/impl/RemoteRootServiceImpl.kt index 2101e73e7c..7782d90df6 100644 --- a/source/core/rootservice/src/main/kotlin/com/xayah/core/rootservice/impl/RemoteRootServiceImpl.kt +++ b/source/core/rootservice/src/main/kotlin/com/xayah/core/rootservice/impl/RemoteRootServiceImpl.kt @@ -14,6 +14,10 @@ import android.content.pm.PackageManager.PackageInfoFlags import android.content.pm.PackageManagerHidden import android.content.pm.PermissionInfo import android.content.pm.UserInfo +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import android.media.ThumbnailUtils import android.os.Build import android.os.Parcel import android.os.ParcelFileDescriptor @@ -27,6 +31,7 @@ import androidx.core.content.pm.PermissionInfoCompat import com.android.server.display.DisplayControl import com.topjohnwu.superuser.ShellUtils import com.xayah.core.datastore.ConstantUtil.DEFAULT_IDLE_TIMEOUT +import com.xayah.core.datastore.ConstantUtil import com.xayah.core.hiddenapi.castTo import com.xayah.core.model.database.PackagePermission import com.xayah.core.rootservice.IRemoteRootService @@ -42,6 +47,7 @@ import com.xayah.core.util.PathUtil import com.xayah.core.util.command.BaseUtil.setAllPermissions import com.xayah.libnative.NativeLib import java.io.File +import java.io.FileOutputStream import java.io.IOException import java.nio.file.FileVisitResult import java.nio.file.Files @@ -516,4 +522,77 @@ internal class RemoteRootServiceImpl(private val context: Context) : IRemoteRoot } override fun calculateMD5(src: String): String = synchronized(lock) { HashUtil.calculateMD5(src) } + + override fun generateMediaThumbnail(src: String, dst: String, size: Int): String = synchronized(lock) { + try { + val ext = PathUtil.getFileName(src).substringAfterLast('.', "").lowercase() + val bitmap = when (ext) { + in ConstantUtil.MediaImageExtensions -> decodeSampledBitmap(src, size) + in ConstantUtil.MediaVideoExtensions -> extractVideoFrame(src, size) + in ConstantUtil.MediaAudioExtensions -> extractAudioArt(src, size) + else -> null + } ?: return "" + File(PathUtil.getParentPath(dst)).mkdirs() + FileOutputStream(dst).use { out -> + if (bitmap.compress(Bitmap.CompressFormat.JPEG, 85, out).not()) return "" + } + if (bitmap.isRecycled.not()) bitmap.recycle() + dst + } catch (e: Exception) { + "" + } + } + + private fun decodeSampledBitmap(src: String, size: Int): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(src, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + var sampleSize = 1 + while (maxOf(bounds.outWidth, bounds.outHeight) / (sampleSize * 2) >= size) sampleSize *= 2 + return BitmapFactory.decodeFile(src, BitmapFactory.Options().apply { inSampleSize = sampleSize }) + } + + private fun extractVideoFrame(src: String, size: Int): Bitmap? { + val retriever = MediaMetadataRetriever() + return try { + retriever.setDataSource(src) + val frame = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + retriever.getScaledFrameAtTime(-1, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, size, size) + } else { + retriever.getFrameAtTime(-1) + } ?: return null + if (frame.width <= size && frame.height <= size) return frame + ThumbnailUtils.extractThumbnail(frame, size, size).also { + if (it != frame && frame.isRecycled.not()) frame.recycle() + } + } catch (e: Exception) { + null + } finally { + try { + retriever.release() + } catch (e: Exception) { + } + } + } + + private fun extractAudioArt(src: String, size: Int): Bitmap? { + val retriever = MediaMetadataRetriever() + return try { + retriever.setDataSource(src) + val art = retriever.embeddedPicture ?: return null + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(art, 0, art.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + var sampleSize = 1 + while (maxOf(bounds.outWidth, bounds.outHeight) / (sampleSize * 2) >= size) sampleSize *= 2 + BitmapFactory.decodeByteArray(art, 0, art.size, BitmapFactory.Options().apply { inSampleSize = sampleSize }) + } catch (e: Exception) { + null + } finally { + try { + retriever.release() + } catch (e: Exception) { + } + } + } } diff --git a/source/core/rootservice/src/main/kotlin/com/xayah/core/rootservice/service/RemoteRootService.kt b/source/core/rootservice/src/main/kotlin/com/xayah/core/rootservice/service/RemoteRootService.kt index dbcbca1927..42dae69985 100644 --- a/source/core/rootservice/src/main/kotlin/com/xayah/core/rootservice/service/RemoteRootService.kt +++ b/source/core/rootservice/src/main/kotlin/com/xayah/core/rootservice/service/RemoteRootService.kt @@ -328,6 +328,13 @@ class RemoteRootService(private val context: Context) { suspend fun calculateMD5(src: String): String? = runCatching { getService().calculateMD5(src) }.onFailure(onFailure).getOrNull() + /** + * Generate a downscaled thumbnail for an image/video/audio file as root + * and write it to [dst]. Returns [dst] on success, "" otherwise. + */ + suspend fun generateMediaThumbnail(src: String, dst: String, size: Int): String = + runCatching { getService().generateMediaThumbnail(src, dst, size) }.onFailure(onFailure).getOrDefault("") + suspend fun writeJson(data: Any, dst: String): ShellResult = runCatching { var isSuccess: Boolean val out = mutableListOf() diff --git a/source/core/ui/src/main/kotlin/com/xayah/core/ui/route/Routes.kt b/source/core/ui/src/main/kotlin/com/xayah/core/ui/route/Routes.kt index 41ad280ff8..2551963529 100644 --- a/source/core/ui/src/main/kotlin/com/xayah/core/ui/route/Routes.kt +++ b/source/core/ui/src/main/kotlin/com/xayah/core/ui/route/Routes.kt @@ -64,6 +64,14 @@ sealed class MainRoutes(val route: String) { data object Directory : MainRoutes(route = "main_directory") + data object MediaBackup : MainRoutes(route = "main_media_backup") + + data object MediaRestore : MainRoutes(route = "main_media_restore") + + data object WifiBackup : MainRoutes(route = "main_wifi_backup") + + data object WifiRestore : MainRoutes(route = "main_wifi_restore") + data object PackagesBackupProcessing : MainRoutes(route = "main_packages_backup_processing") data object PackagesBackupProcessingSetup : MainRoutes(route = "main_packages_backup_processing_setup") data object PackagesBackupProcessingGraph : MainRoutes(route = "main_packages_backup_processing_graph") diff --git a/source/feature/main/dashboard/src/main/kotlin/com/xayah/feature/main/dashboard/Index.kt b/source/feature/main/dashboard/src/main/kotlin/com/xayah/feature/main/dashboard/Index.kt index 96d966942f..07dca7fcad 100644 --- a/source/feature/main/dashboard/src/main/kotlin/com/xayah/feature/main/dashboard/Index.kt +++ b/source/feature/main/dashboard/src/main/kotlin/com/xayah/feature/main/dashboard/Index.kt @@ -165,6 +165,28 @@ fun PageDashboard() { ) { navController.navigateSingle(MainRoutes.List.getRoute(target = Target.Files, opType = OpType.BACKUP)) } + QuickActionsButton( + modifier = Modifier.weight(1f), + enabled = nullBackupDir.not(), + title = stringResource(id = R.string.backup_media), + icon = ImageVector.vectorResource(id = R.drawable.ic_rounded_acute), + colorContainer = ThemedColorSchemeKeyTokens.GreenPrimaryContainer, + colorL80D20 = ThemedColorSchemeKeyTokens.GreenL80D20, + onColorContainer = ThemedColorSchemeKeyTokens.GreenOnPrimaryContainer + ) { + navController.navigateSingle(MainRoutes.MediaBackup.route) + } + QuickActionsButton( + modifier = Modifier.weight(1f), + enabled = nullBackupDir.not(), + title = stringResource(id = R.string.backup_wifi), + icon = ImageVector.vectorResource(id = R.drawable.ic_rounded_acute), + colorContainer = ThemedColorSchemeKeyTokens.PinkPrimaryContainer, + colorL80D20 = ThemedColorSchemeKeyTokens.PinkL80D20, + onColorContainer = ThemedColorSchemeKeyTokens.PinkOnPrimaryContainer + ) { + navController.navigateSingle(MainRoutes.WifiBackup.route) + } // TODO MMS/SMS, Contacts backup/restore // QuickActionsButton( // modifier = Modifier.weight(1f), @@ -208,6 +230,30 @@ fun PageDashboard() { ) { navController.navigateSingle(MainRoutes.Restore.route) } + QuickActionsButton( + modifier = Modifier.weight(1f), + enabled = nullBackupDir.not(), + title = stringResource(id = R.string.restore_media), + icon = ImageVector.vectorResource(id = R.drawable.ic_rounded_history), + colorContainer = ThemedColorSchemeKeyTokens.BluePrimaryContainer, + colorL80D20 = ThemedColorSchemeKeyTokens.BlueL80D20, + onColorContainer = ThemedColorSchemeKeyTokens.BlueOnPrimaryContainer, + actionIcon = Icons.Rounded.KeyboardArrowRight + ) { + navController.navigateSingle(MainRoutes.MediaRestore.route) + } + QuickActionsButton( + modifier = Modifier.weight(1f), + enabled = nullBackupDir.not(), + title = stringResource(id = R.string.restore_wifi), + icon = ImageVector.vectorResource(id = R.drawable.ic_rounded_history), + colorContainer = ThemedColorSchemeKeyTokens.OrangePrimaryContainer, + colorL80D20 = ThemedColorSchemeKeyTokens.OrangeL80D20, + onColorContainer = ThemedColorSchemeKeyTokens.OrangeOnPrimaryContainer, + actionIcon = Icons.Rounded.KeyboardArrowRight + ) { + navController.navigateSingle(MainRoutes.WifiRestore.route) + } QuickActionsButton( modifier = Modifier.weight(1f), enabled = nullBackupDir.not(), diff --git a/source/feature/main/dashboard/src/main/res/values/ids.xml b/source/feature/main/dashboard/src/main/res/values/ids.xml index 6aaa211f23..c24a11d6cc 100644 --- a/source/feature/main/dashboard/src/main/res/values/ids.xml +++ b/source/feature/main/dashboard/src/main/res/values/ids.xml @@ -10,6 +10,10 @@ + + + + diff --git a/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/MediaRestoreScreen.kt b/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/MediaRestoreScreen.kt new file mode 100644 index 0000000000..a78976992b --- /dev/null +++ b/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/MediaRestoreScreen.kt @@ -0,0 +1,171 @@ +package com.xayah.feature.main.list + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.xayah.core.model.MediaKind +import com.xayah.core.ui.component.Divider +import com.xayah.core.ui.component.IconButton +import com.xayah.core.ui.component.InnerTopSpacer +import com.xayah.core.ui.component.LinearProgressIndicator +import com.xayah.core.ui.component.SecondaryTopBar +import com.xayah.core.ui.token.SizeTokens +import com.xayah.core.ui.util.LocalNavController + +@Composable +fun MediaRestoreRoute( + viewModel: MediaRestoreViewModel = hiltViewModel(), +) { + val navController = LocalNavController.current!! + val uiState: MediaUiState by viewModel.uiState.collectAsStateWithLifecycle() + MediaRestoreScreen( + uiState = uiState, + onToggle = viewModel::toggle, + onSelectAll = viewModel::selectAll, + onUnselectAll = viewModel::unselectAll, + onReload = viewModel::reload, + onRestore = { viewModel.restoreSelected(navController) }, + onLoadThumbnail = viewModel::loadThumbnail, + ) +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +internal fun MediaRestoreScreen( + uiState: MediaUiState, + onToggle: (String) -> Unit, + onSelectAll: (MediaKind) -> Unit, + onUnselectAll: (MediaKind) -> Unit, + onReload: () -> Unit, + onRestore: () -> Unit, + onLoadThumbnail: suspend (String) -> String?, +) { + val selected = (uiState as? MediaUiState.Success)?.selected.orEmpty() + val total = (uiState as? MediaUiState.Success)?.total ?: 0 + var tabIndex by remember { mutableIntStateOf(1) } + + Scaffold( + topBar = { + SecondaryTopBar( + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()), + title = stringResource(id = R.string.restore_media), + subtitle = if (uiState is MediaUiState.Success) "(${selected.size}/${total})" else null, + actions = { + IconButton(icon = Icons.Rounded.Refresh) { + onReload() + } + }, + ) + }, + floatingActionButtonPosition = FabPosition.End, + floatingActionButton = { + AnimatedVisibility(visible = selected.isNotEmpty(), enter = scaleIn(), exit = scaleOut()) { + ExtendedFloatingActionButton( + onClick = onRestore, + icon = { Icon(Icons.Rounded.ChevronRight, null) }, + text = { Text(text = stringResource(id = R.string._continue)) }, + ) + } + } + ) { innerPadding -> + Column { + InnerTopSpacer(innerPadding = innerPadding) + + if (uiState is MediaUiState.Loading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = stringResource(id = R.string.media_scanning)) + } + } else if (uiState is MediaUiState.Success) { + MediaTabs( + imagesCount = uiState.images.size, + videosCount = uiState.videos.size, + audioCount = uiState.audio.size, + selectedTab = tabIndex, + onTabClick = { tabIndex = it }, + ) + val kind = when (tabIndex) { + 0 -> MediaKind.Images + 2 -> MediaKind.Audio + else -> MediaKind.Videos + } + val items = when (kind) { + MediaKind.Images -> uiState.images + MediaKind.Videos -> uiState.videos + MediaKind.Audio -> uiState.audio + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SizeTokens.Level16), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { onSelectAll(kind) }) { + Text(text = stringResource(id = R.string.select_all)) + } + TextButton(onClick = { onUnselectAll(kind) }) { + Text(text = stringResource(id = R.string.unselect_all)) + } + } + if (items.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = stringResource(id = R.string.media_empty)) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = rememberLazyListState(), + ) { + items(items, key = { it.path }) { item -> + var thumbPath by remember(item.path) { mutableStateOf(null) } + LaunchedEffect(item.path) { + thumbPath = onLoadThumbnail(item.path) + } + MediaRow( + item = item, + checked = selected.contains(item.path), + thumbnail = thumbPath, + onToggle = { onToggle(item.path) }, + ) + } + } + } + } + } + } +} diff --git a/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/MediaRestoreViewModel.kt b/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/MediaRestoreViewModel.kt new file mode 100644 index 0000000000..5fc5f70c23 --- /dev/null +++ b/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/MediaRestoreViewModel.kt @@ -0,0 +1,113 @@ +package com.xayah.feature.main.list + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import com.xayah.core.data.repository.FilesRepo +import com.xayah.core.model.MediaKind +import com.xayah.core.model.ScannedMediaFile +import com.xayah.core.ui.route.MainRoutes +import com.xayah.core.util.ifEmptyEncodeURLWithSpace +import com.xayah.core.util.launchOnDefault +import com.xayah.core.util.localBackupSaveDir +import com.xayah.core.util.navigateSingle +import com.xayah.core.work.WorkManagerInitializer +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +class MediaRestoreViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val filesRepo: FilesRepo, +) : ViewModel() { + val uiState: StateFlow = filesRepo.getLocalRestoreMedia().map { entities -> + val items = entities.mapNotNull { e -> + filesRepo.kindOfMedia(e.path)?.let { kind -> Triple(e.id, e, kind) } + } + MediaUiState.Success( + images = items.filter { it.third == MediaKind.Images } + .map { ScannedMediaFile(path = it.second.path, name = it.second.name, kind = it.third) }, + videos = items.filter { it.third == MediaKind.Videos } + .map { ScannedMediaFile(path = it.second.path, name = it.second.name, kind = it.third) }, + audio = items.filter { it.third == MediaKind.Audio } + .map { ScannedMediaFile(path = it.second.path, name = it.second.name, kind = it.third) }, + selected = items.filter { it.second.extraInfo.activated }.map { it.second.path }.toSet(), + idByPath = items.associate { it.second.path to it.first }, + ) + }.stateIn( + scope = viewModelScope, + initialValue = MediaUiState.Loading, + started = SharingStarted.WhileSubscribed(5_000), + ) + + fun reload() { + viewModelScope.launchOnDefault { + WorkManagerInitializer.loadFileBackups(context, "", "") + } + } + + fun toggle(path: String) { + viewModelScope.launchOnDefault { + val state = uiState.value + if (state is MediaUiState.Success) { + val id = state.idByPath[path] ?: return@launchOnDefault + filesRepo.selectFile(id, state.selected.contains(path).not()) + } + } + } + + fun selectAll(kind: MediaKind) { + viewModelScope.launchOnDefault { + val state = uiState.value + if (state is MediaUiState.Success) { + val paths = when (kind) { + MediaKind.Images -> state.images + MediaKind.Videos -> state.videos + MediaKind.Audio -> state.audio + }.map { it.path } + filesRepo.selectAll(paths.mapNotNull { state.idByPath[it] }) + } + } + } + + fun unselectAll(kind: MediaKind) { + viewModelScope.launchOnDefault { + val state = uiState.value + if (state is MediaUiState.Success) { + val paths = when (kind) { + MediaKind.Images -> state.images + MediaKind.Videos -> state.videos + MediaKind.Audio -> state.audio + }.map { it.path } + filesRepo.unselectAll(paths.mapNotNull { state.idByPath[it] }) + } + } + } + + fun restoreSelected(navController: NavController) { + viewModelScope.launchOnDefault { + val selected = (uiState.value as? MediaUiState.Success)?.selected.orEmpty() + if (selected.isNotEmpty()) { + withContext(Dispatchers.Main) { + navController.navigateSingle( + MainRoutes.MediumRestoreProcessingGraph.getRoute( + cloudName = "".ifEmptyEncodeURLWithSpace(), + backupDir = context.localBackupSaveDir().ifEmptyEncodeURLWithSpace(), + ) + ) + } + } + } + } + + suspend fun loadThumbnail(path: String): String? = + runCatching { filesRepo.getMediaThumbnail(path) }.getOrNull() +} diff --git a/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/MediaScreen.kt b/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/MediaScreen.kt new file mode 100644 index 0000000000..fd06132264 --- /dev/null +++ b/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/MediaScreen.kt @@ -0,0 +1,286 @@ +package com.xayah.feature.main.list + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.Movie +import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import java.io.File +import com.xayah.core.model.MediaKind +import com.xayah.core.model.ScannedMediaFile +import com.xayah.core.ui.component.Divider +import com.xayah.core.ui.component.IconButton +import com.xayah.core.ui.component.InnerTopSpacer +import com.xayah.core.ui.component.LinearProgressIndicator +import com.xayah.core.ui.component.SecondaryTopBar +import com.xayah.core.ui.token.SizeTokens +import com.xayah.core.ui.util.LocalNavController + +@Composable +fun MediaRoute( + viewModel: MediaViewModel = hiltViewModel(), +) { + val navController = LocalNavController.current!! + val uiState: MediaUiState by viewModel.uiState.collectAsStateWithLifecycle() + MediaScreen( + uiState = uiState, + onToggle = viewModel::toggle, + onSelectAll = viewModel::selectAll, + onUnselectAll = viewModel::unselectAll, + onRescan = viewModel::scan, + onBackup = { viewModel.backupSelected(navController) }, + onLoadThumbnail = viewModel::loadThumbnail, + ) +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +internal fun MediaScreen( + uiState: MediaUiState, + onToggle: (String) -> Unit, + onSelectAll: (MediaKind) -> Unit, + onUnselectAll: (MediaKind) -> Unit, + onRescan: () -> Unit, + onBackup: () -> Unit, + onLoadThumbnail: suspend (String) -> String?, +) { + val selected = (uiState as? MediaUiState.Success)?.selected.orEmpty() + val total = (uiState as? MediaUiState.Success)?.total ?: 0 + var tabIndex by remember { mutableIntStateOf(1) } + + Scaffold( + topBar = { + SecondaryTopBar( + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()), + title = stringResource(id = R.string.backup_media), + subtitle = if (uiState is MediaUiState.Success) "(${selected.size}/${total})" else null, + actions = { + IconButton(icon = Icons.Rounded.Refresh) { + onRescan() + } + }, + ) + }, + floatingActionButtonPosition = FabPosition.End, + floatingActionButton = { + AnimatedVisibility(visible = selected.isNotEmpty(), enter = scaleIn(), exit = scaleOut()) { + ExtendedFloatingActionButton( + onClick = onBackup, + icon = { Icon(Icons.Rounded.ChevronRight, null) }, + text = { Text(text = stringResource(id = R.string._continue)) }, + ) + } + } + ) { innerPadding -> + Column { + InnerTopSpacer(innerPadding = innerPadding) + + if (uiState is MediaUiState.Loading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = stringResource(id = R.string.media_scanning)) + } + } else if (uiState is MediaUiState.Success) { + MediaTabs( + imagesCount = uiState.images.size, + videosCount = uiState.videos.size, + audioCount = uiState.audio.size, + selectedTab = tabIndex, + onTabClick = { tabIndex = it }, + ) + val kind = when (tabIndex) { + 0 -> MediaKind.Images + 2 -> MediaKind.Audio + else -> MediaKind.Videos + } + val items = when (kind) { + MediaKind.Images -> uiState.images + MediaKind.Videos -> uiState.videos + MediaKind.Audio -> uiState.audio + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SizeTokens.Level16), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { onSelectAll(kind) }) { + Text(text = stringResource(id = R.string.select_all)) + } + TextButton(onClick = { onUnselectAll(kind) }) { + Text(text = stringResource(id = R.string.unselect_all)) + } + } + if (items.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = stringResource(id = R.string.media_empty)) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = rememberLazyListState(), + ) { + items(items, key = { it.path }) { item -> + var thumbPath by remember(item.path) { mutableStateOf(null) } + LaunchedEffect(item.path) { + thumbPath = onLoadThumbnail(item.path) + } + MediaRow( + item = item, + checked = selected.contains(item.path), + thumbnail = thumbPath, + onToggle = { onToggle(item.path) }, + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +internal fun MediaTabs( + imagesCount: Int, + videosCount: Int, + audioCount: Int, + selectedTab: Int, + onTabClick: (Int) -> Unit, +) { + val titles = listOf( + "${stringResource(id = R.string.media_images)} (${imagesCount})", + "${stringResource(id = R.string.media_videos)} (${videosCount})", + "${stringResource(id = R.string.media_audio)} (${audioCount})", + ) + PrimaryScrollableTabRow( + selectedTabIndex = selectedTab, + edgePadding = SizeTokens.Level0, + indicator = @Composable { + TabRowDefaults.PrimaryIndicator( + Modifier.tabIndicatorOffset(selectedTab, matchContentSize = true), + shape = CircleShape + ) + }, + divider = { + Divider(modifier = Modifier.fillMaxWidth()) + } + ) { + titles.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { onTabClick(index) }, + text = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + ) + } + } +} + +@Composable +internal fun MediaRow( + item: ScannedMediaFile, + checked: Boolean, + thumbnail: String?, + onToggle: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(horizontal = SizeTokens.Level16, vertical = SizeTokens.Level8), + verticalAlignment = Alignment.CenterVertically, + ) { + MediaThumbnail(kind = item.kind, thumbnail = thumbnail) + Checkbox(checked = checked, onCheckedChange = { onToggle() }) + Column(modifier = Modifier.weight(1f)) { + Text(text = item.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(text = item.path, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + Divider(modifier = Modifier.fillMaxWidth()) +} + +@Composable +private fun MediaThumbnail(kind: MediaKind, thumbnail: String?) { + val shape = RoundedCornerShape(12.dp) + var failed by remember(thumbnail) { mutableStateOf(false) } + if (thumbnail == null || failed) { + Box( + modifier = Modifier + .size(56.dp) + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = when (kind) { + MediaKind.Images -> Icons.Rounded.Image + MediaKind.Videos -> Icons.Rounded.Movie + MediaKind.Audio -> Icons.Rounded.MusicNote + }, + contentDescription = null, + ) + } + } else { + AsyncImage( + model = File(thumbnail), + contentDescription = null, + modifier = Modifier + .size(56.dp) + .clip(shape), + contentScale = ContentScale.Crop, + onError = { failed = true }, + ) + } +} diff --git a/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/MediaViewModel.kt b/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/MediaViewModel.kt new file mode 100644 index 0000000000..b2f8a01085 --- /dev/null +++ b/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/MediaViewModel.kt @@ -0,0 +1,104 @@ +package com.xayah.feature.main.list + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import com.xayah.core.data.repository.FilesRepo +import com.xayah.core.model.MediaKind +import com.xayah.core.model.ScannedMediaFile +import com.xayah.core.ui.route.MainRoutes +import com.xayah.core.util.launchOnDefault +import com.xayah.core.util.navigateSingle +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +class MediaViewModel @Inject constructor( + private val filesRepo: FilesRepo, +) : ViewModel() { + private val _uiState = MutableStateFlow(MediaUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + scan() + } + + fun scan() { + viewModelScope.launchOnDefault { + _uiState.value = MediaUiState.Loading + val files = runCatching { filesRepo.scanMediaFiles() }.getOrDefault(listOf()) + _uiState.value = MediaUiState.Success( + images = files.filter { it.kind == MediaKind.Images }, + videos = files.filter { it.kind == MediaKind.Videos }, + audio = files.filter { it.kind == MediaKind.Audio }, + selected = setOf(), + ) + } + } + + fun toggle(path: String) { + val state = _uiState.value + if (state is MediaUiState.Success) { + val selected = state.selected.toMutableSet() + if (selected.contains(path)) selected.remove(path) else selected.add(path) + _uiState.value = state.copy(selected = selected) + } + } + + fun selectAll(kind: MediaKind) { + val state = _uiState.value + if (state is MediaUiState.Success) { + val paths = when (kind) { + MediaKind.Images -> state.images + MediaKind.Videos -> state.videos + MediaKind.Audio -> state.audio + }.map { it.path } + _uiState.value = state.copy(selected = state.selected + paths) + } + } + + fun unselectAll(kind: MediaKind) { + val state = _uiState.value + if (state is MediaUiState.Success) { + val paths = when (kind) { + MediaKind.Images -> state.images + MediaKind.Videos -> state.videos + MediaKind.Audio -> state.audio + }.map { it.path }.toSet() + _uiState.value = state.copy(selected = state.selected - paths) + } + } + + fun backupSelected(navController: NavController) { + viewModelScope.launchOnDefault { + val selected = (uiState.value as? MediaUiState.Success)?.selected?.toList().orEmpty() + if (selected.isNotEmpty()) { + filesRepo.prepareMediaBackup(selected) + withContext(Dispatchers.Main) { + navController.navigateSingle(MainRoutes.MediumBackupProcessingGraph.route) + } + } + } + } + + suspend fun loadThumbnail(path: String): String? = + runCatching { filesRepo.getMediaThumbnail(path) }.getOrNull() +} + +sealed interface MediaUiState { + data object Loading : MediaUiState + data class Success( + val images: List, + val videos: List, + val audio: List, + val selected: Set, + val idByPath: Map = mapOf(), + ) : MediaUiState { + val total: Int get() = images.size + videos.size + audio.size + } +} diff --git a/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/WifiRestoreViewModel.kt b/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/WifiRestoreViewModel.kt new file mode 100644 index 0000000000..e2044d75f1 --- /dev/null +++ b/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/WifiRestoreViewModel.kt @@ -0,0 +1,90 @@ +package com.xayah.feature.main.list + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import com.xayah.core.data.repository.FilesRepo +import com.xayah.core.ui.route.MainRoutes +import com.xayah.core.util.ifEmptyEncodeURLWithSpace +import com.xayah.core.util.launchOnDefault +import com.xayah.core.util.localBackupSaveDir +import com.xayah.core.util.navigateSingle +import com.xayah.core.work.WorkManagerInitializer +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +class WifiRestoreViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val filesRepo: FilesRepo, +) : ViewModel() { + val uiState: StateFlow = filesRepo.getLocalRestoreWifi().map { entities -> + WifiUiState.Success( + items = entities.map { WifiItem(name = it.name, path = it.path) }, + selected = entities.filter { it.extraInfo.activated }.map { it.path }.toSet(), + idByPath = entities.associate { it.path to it.id }, + ) + }.stateIn( + scope = viewModelScope, + initialValue = WifiUiState.Loading, + started = SharingStarted.WhileSubscribed(5_000), + ) + + fun reload() { + viewModelScope.launchOnDefault { + WorkManagerInitializer.loadFileBackups(context, "", "") + } + } + + fun toggle(path: String) { + viewModelScope.launchOnDefault { + val state = uiState.value + if (state is WifiUiState.Success) { + val id = state.idByPath[path] ?: return@launchOnDefault + filesRepo.selectFile(id, state.selected.contains(path).not()) + } + } + } + + fun selectAll() { + viewModelScope.launchOnDefault { + val state = uiState.value + if (state is WifiUiState.Success) { + filesRepo.selectAll(state.items.mapNotNull { state.idByPath[it.path] }) + } + } + } + + fun unselectAll() { + viewModelScope.launchOnDefault { + val state = uiState.value + if (state is WifiUiState.Success) { + filesRepo.unselectAll(state.items.mapNotNull { state.idByPath[it.path] }) + } + } + } + + fun restoreSelected(navController: NavController) { + viewModelScope.launchOnDefault { + val selected = (uiState.value as? WifiUiState.Success)?.selected.orEmpty() + if (selected.isNotEmpty()) { + withContext(Dispatchers.Main) { + navController.navigateSingle( + MainRoutes.MediumRestoreProcessingGraph.getRoute( + cloudName = "".ifEmptyEncodeURLWithSpace(), + backupDir = context.localBackupSaveDir().ifEmptyEncodeURLWithSpace(), + ) + ) + } + } + } + } +} diff --git a/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/WifiScreen.kt b/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/WifiScreen.kt new file mode 100644 index 0000000000..bb6d65cc45 --- /dev/null +++ b/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/WifiScreen.kt @@ -0,0 +1,206 @@ +package com.xayah.feature.main.list + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.xayah.core.ui.component.Divider +import com.xayah.core.ui.component.IconButton +import com.xayah.core.ui.component.InnerTopSpacer +import com.xayah.core.ui.component.LinearProgressIndicator +import com.xayah.core.ui.component.SecondaryTopBar +import com.xayah.core.ui.token.SizeTokens +import com.xayah.core.ui.util.LocalNavController + +@Composable +fun WifiRoute( + viewModel: WifiViewModel = hiltViewModel(), +) { + val navController = LocalNavController.current!! + val uiState: WifiUiState by viewModel.uiState.collectAsStateWithLifecycle() + WifiScreen( + uiState = uiState, + titleRes = R.string.backup_wifi, + loadingRes = R.string.wifi_scanning, + emptyRes = R.string.wifi_empty, + onToggle = viewModel::toggle, + onSelectAll = viewModel::selectAll, + onUnselectAll = viewModel::unselectAll, + onRefresh = viewModel::scan, + onContinue = { viewModel.backupSelected(navController) }, + ) +} + +@Composable +fun WifiRestoreRoute( + viewModel: WifiRestoreViewModel = hiltViewModel(), +) { + val navController = LocalNavController.current!! + val uiState: WifiUiState by viewModel.uiState.collectAsStateWithLifecycle() + WifiScreen( + uiState = uiState, + titleRes = R.string.restore_wifi, + loadingRes = R.string.wifi_scanning, + emptyRes = R.string.wifi_empty, + onToggle = viewModel::toggle, + onSelectAll = viewModel::selectAll, + onUnselectAll = viewModel::unselectAll, + onRefresh = viewModel::reload, + onContinue = { viewModel.restoreSelected(navController) }, + ) +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +internal fun WifiScreen( + uiState: WifiUiState, + titleRes: Int, + loadingRes: Int, + emptyRes: Int, + onToggle: (String) -> Unit, + onSelectAll: () -> Unit, + onUnselectAll: () -> Unit, + onRefresh: () -> Unit, + onContinue: () -> Unit, +) { + val items = (uiState as? WifiUiState.Success)?.items.orEmpty() + val selected = (uiState as? WifiUiState.Success)?.selected.orEmpty() + + Scaffold( + topBar = { + SecondaryTopBar( + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()), + title = stringResource(id = titleRes), + subtitle = if (uiState is WifiUiState.Success) "(${selected.size}/${items.size})" else null, + actions = { + IconButton(icon = Icons.Rounded.Refresh) { + onRefresh() + } + }, + ) + }, + floatingActionButtonPosition = FabPosition.End, + floatingActionButton = { + AnimatedVisibility(visible = selected.isNotEmpty(), enter = scaleIn(), exit = scaleOut()) { + ExtendedFloatingActionButton( + onClick = onContinue, + icon = { Icon(Icons.Rounded.ChevronRight, null) }, + text = { Text(text = stringResource(id = R.string._continue)) }, + ) + } + } + ) { innerPadding -> + Column { + InnerTopSpacer(innerPadding = innerPadding) + + if (uiState is WifiUiState.Loading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = stringResource(id = loadingRes)) + } + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SizeTokens.Level16), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onSelectAll) { + Text(text = stringResource(id = R.string.select_all)) + } + TextButton(onClick = onUnselectAll) { + Text(text = stringResource(id = R.string.unselect_all)) + } + } + if (items.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = stringResource(id = emptyRes)) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = rememberLazyListState(), + ) { + items(items, key = { it.path }) { item -> + WifiRow( + item = item, + checked = selected.contains(item.path), + onToggle = { onToggle(item.path) }, + ) + } + } + } + } + } + } +} + +@Composable +private fun WifiRow( + item: WifiItem, + checked: Boolean, + onToggle: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(horizontal = SizeTokens.Level16, vertical = SizeTokens.Level8), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Icon(imageVector = Icons.Rounded.Wifi, contentDescription = null) + } + Checkbox(checked = checked, onCheckedChange = { onToggle() }) + Column(modifier = Modifier.weight(1f)) { + Text(text = item.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(text = item.path, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + Divider(modifier = Modifier.fillMaxWidth()) +} diff --git a/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/WifiViewModel.kt b/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/WifiViewModel.kt new file mode 100644 index 0000000000..7e0cbd3c71 --- /dev/null +++ b/source/feature/main/list/src/main/kotlin/com/xayah/feature/main/list/WifiViewModel.kt @@ -0,0 +1,88 @@ +package com.xayah.feature.main.list + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import com.xayah.core.data.repository.FilesRepo +import com.xayah.core.ui.route.MainRoutes +import com.xayah.core.util.launchOnDefault +import com.xayah.core.util.navigateSingle +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import javax.inject.Inject + +data class WifiItem( + val name: String, + val path: String, +) + +sealed interface WifiUiState { + data object Loading : WifiUiState + data class Success( + val items: List, + val selected: Set, + val idByPath: Map = mapOf(), + ) : WifiUiState +} + +@HiltViewModel +class WifiViewModel @Inject constructor( + private val filesRepo: FilesRepo, +) : ViewModel() { + private val _uiState = MutableStateFlow(WifiUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + scan() + } + + fun scan() { + viewModelScope.launchOnDefault { + _uiState.value = WifiUiState.Loading + val found = runCatching { filesRepo.scanWifiConfigs() }.getOrDefault(listOf()) + _uiState.value = WifiUiState.Success( + items = found.map { (name, path) -> WifiItem(name = name, path = path) }, + selected = setOf(), + ) + } + } + + fun toggle(path: String) { + val state = _uiState.value + if (state is WifiUiState.Success) { + val selected = state.selected.toMutableSet() + if (selected.contains(path)) selected.remove(path) else selected.add(path) + _uiState.value = state.copy(selected = selected) + } + } + + fun selectAll() { + val state = _uiState.value + if (state is WifiUiState.Success) { + _uiState.value = state.copy(selected = state.items.map { it.path }.toSet()) + } + } + + fun unselectAll() { + val state = _uiState.value + if (state is WifiUiState.Success) { + _uiState.value = state.copy(selected = setOf()) + } + } + + fun backupSelected(navController: NavController) { + viewModelScope.launchOnDefault { + val selected = (_uiState.value as? WifiUiState.Success)?.selected?.toList().orEmpty() + if (selected.isNotEmpty()) { + filesRepo.prepareMediaBackup(selected) + withContext(Dispatchers.Main) { + navController.navigateSingle(MainRoutes.MediumBackupProcessingGraph.route) + } + } + } + } +} diff --git a/source/feature/main/list/src/main/res/values/ids.xml b/source/feature/main/list/src/main/res/values/ids.xml index e4f2277caa..4888481b2d 100644 --- a/source/feature/main/list/src/main/res/values/ids.xml +++ b/source/feature/main/list/src/main/res/values/ids.xml @@ -25,6 +25,17 @@ + + + + + + + + + + +