Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions source/app/src/main/kotlin/com/xayah/databackup/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down
11 changes: 11 additions & 0 deletions source/app/src/main/res/values-ar/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,17 @@
<string name="confirm_delete">هذا سيحذف كل الملفات التي تم اختيارها</string>
<string name="custom_storage">مساحة تخزين مخصصة</string>
<string name="select_target_directory">اختيار الملف المراد</string>
<string name="backup_media">نسخ الميديا</string>
<string name="media_images">صور</string>
<string name="media_videos">فيديوهات</string>
<string name="media_audio">أغاني</string>
<string name="media_scanning">جارٍ فحص الجهاز عن الميديا…</string>
<string name="media_empty">لا توجد ملفات</string>
<string name="restore_media">استعادة الميديا</string>
<string name="backup_wifi">نسخ الواي فاي</string>
<string name="restore_wifi">استعادة الواي فاي</string>
<string name="wifi_scanning">جارٍ فحص الشبكات المحفوظة…</string>
<string name="wifi_empty">لا توجد شبكات محفوظة</string>
<string name="update_available">تحديث متوفر</string>
<string name="installed">مثبت</string>
<string name="delete">حذف</string>
Expand Down
11 changes: 11 additions & 0 deletions source/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,17 @@
<string name="add">Add</string>
<string name="custom_storage">Custom storage</string>
<string name="select_target_directory">Select target folder</string>
<string name="backup_media">Backup media</string>
<string name="media_images">Images</string>
<string name="media_videos">Videos</string>
<string name="media_audio">Songs</string>
<string name="media_scanning">Scanning device for media…</string>
<string name="media_empty">No files found</string>
<string name="restore_media">Restore media</string>
<string name="backup_wifi">Backup Wi-Fi</string>
<string name="restore_wifi">Restore Wi-Fi</string>
<string name="wifi_scanning">Scanning for saved networks…</string>
<string name="wifi_empty">No saved networks found</string>
<string name="confirm_delete">This will delete all the related files you selected</string>
<string name="backup_dir_as_media_error">Cannot add backup directory as media</string>
<string name="media_target_path_missing">The target folder is missing. Please set it manually.</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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 } }
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<Pair<String, String>> {
val found = mutableListOf<Pair<String, String>>()
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<List<MediaEntity>> =
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<ScannedMediaFile> {
val found = mutableListOf<ScannedMediaFile>()
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<List<MediaEntity>> =
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<String>) {
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) {
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>()
Expand Down
Loading