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 @@
+
+
+
+
+
+
+
+
+
+
+