9 Commits

Author SHA1 Message Date
sakuradairong
6cdad04905 feat: improve backup quality from Android-DataBackup analysis
Phase 1 — Core Backup Quality:
- Add archive tar structure validation after compression
- Exclude cache/.ota/lib/code_cache/no_backup from data backup
- Add keystore detection with user warning
- Enhance SSAID parsing (XML attribute) and restore via settings put secure

Phase 2 — App Data Model + Multi-user:
- Add DataSizes data class, enrich AppInfo with userId/keystore/iconPath
- Add icon backup from snapshot cache or APK
- Multi-user enumeration and per-user scanning (pm list users)
- User profile selector in backup/restore UI

Phase 3 — UI + Service:
- Add sort (A-Z, size), filter (system apps toggle), select all/deselect all
- New BackupService foreground service to prevent process death
- AndroidManifest: FOREGROUND_SERVICE + POST_NOTIFICATIONS permissions

Phase 4 — Polish:
- Add LogUtil with file rotation (7-day retention, filesDir/logs/)
- Wire file logging into backup/restore entry/exit points
2026-06-02 00:54:30 +08:00
sakuradairong
5cbd21577b fix: correct TransferProgress/ByteProgress field names in snapshot progress 2026-06-01 23:29:46 +08:00
sakuradairong
1bae01de72 perf: add sync progress to restic snapshot loading 2026-06-01 23:14:17 +08:00
sakuradairong
e710c36ee2 fix: BinaryResolver per-binary cache (was sharing incorrect cross-binary state) 2026-06-01 23:03:31 +08:00
sakuradairong
c1bbef4eef feat: bundle zstd and tar binaries in jniLibs for reliable data backup
Download statically compiled ARM64 zstd (1.3MB) and tar (1.1MB)
binaries from YAWAsau/backup_script, placed in jniLibs as
libzstd_bin.so and libtar_bin.so. BinaryResolver extracts them to
filesDir/bin/ with execute permissions.

backupUserData auto-detects:
1. Bundled zstd → use absolute path
2. System zstd → use PATH lookup
3. No zstd → fall back to gzip (tar -czf)

This eliminates the 'command not found' (exit=127) issue when nsenter
+ FLAG_MOUNT_MASTER switches to a namespace with different PATH.
2026-06-01 23:01:17 +08:00
sakuradairong
4c4542e059 fix: auto-detect zstd availability, fall back to gzip when missing
With nsenter + FLAG_MOUNT_MASTER, test -d now correctly finds
/data/data/<pkg>. But zstd binary is absent on this device (previous
backups never reached tar|zstd because dirs were always empty).
Auto-detect zstd before creating archive; fall back to tar -czf (gzip)
when unavailable.
2026-06-01 22:55:08 +08:00
sakuradairong
ef78ab8bec debug: log tar stderr in each fallback step to identify actual error 2026-06-01 22:49:42 +08:00
sakuradairong
a38a483c70 fix: configure libsu with FLAG_MOUNT_MASTER and nsenter global namespace initializer
DataBackup (XayahSuSuSu) approach: configure the default libsu builder
with:
- Shell.FLAG_MOUNT_MASTER (requests su --mount-master from Magisk)
- GlobalNamespaceInitializer: runs 'nsenter --mount=/proc/1/ns/mnt sh'
  at shell init time, switching to init's global mount namespace
  where ALL /data/data/ directories are visible.

This replaces the previous 4-tier fallback hacks with a proper
shell-level fix at the libsu configuration layer.
2026-06-01 22:42:51 +08:00
sakuradairong
d0bfef41c8 fix: replace su -Z with magiskpolicy SELinux relax in backupUserData fallback 2026-06-01 22:33:16 +08:00
15 changed files with 804 additions and 207 deletions

View File

@@ -5,6 +5,9 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
@@ -22,6 +25,11 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".backup.BackupService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>

View File

@@ -12,6 +12,7 @@ import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.example.androidbackupgui.databinding.ActivityMainBinding
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.backup.LogUtil
import com.example.androidbackupgui.ui.BackupFragment
import com.example.androidbackupgui.ui.ConfigFragment
import com.example.androidbackupgui.ui.RestoreFragment
@@ -34,9 +35,15 @@ class MainActivity : AppCompatActivity() {
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// Configure libsu with global mount namespace support
RootShell.configure()
// Request root access on startup
lifecycleScope.launch(Dispatchers.IO) {
RootShell.ensureSession()
// Initialize file-based logging
LogUtil.init(filesDir)
}
// Edge-to-edge: pad toolbar below status bar

View File

@@ -7,6 +7,15 @@ import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
@Serializable
data class DataSizes(
val apkBytes: Long = 0,
val userBytes: Long = 0,
val userDeBytes: Long = 0,
val dataBytes: Long = 0,
val obbBytes: Long = 0,
val mediaBytes: Long = 0,
)
@Serializable
data class AppInfo(
@@ -16,27 +25,32 @@ data class AppInfo(
val apkPaths: List<String> = emptyList(),
val hasObb: Boolean = false,
val isRunning: Boolean = false,
val backupSize: Long = 0 // estimated from last backup
val backupSize: Long = 0, // estimated from last backup
// Enhanced fields (multi-user, keystore, icon)
val userId: Int = 0,
val hasKeystore: Boolean = false,
val iconPath: String? = null,
val dataSizes: DataSizes = DataSizes(),
)
object AppScanner {
/** Scan all third-party installed packages. */
suspend fun scanThirdParty(context: Context): List<AppInfo> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list packages -3")
suspend fun scanThirdParty(context: Context, userId: Int = 0): List<AppInfo> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list packages -3 --user $userId")
if (!result.isSuccess) return@withContext emptyList()
val packages = result.output.lines()
.filter { it.startsWith("package:") }
.map { it.removePrefix("package:").trim() }
.filter { it.isNotEmpty() }
.map { AppInfo(packageName = it) }
.map { AppInfo(packageName = it, userId = userId) }
resolveLabels(context, packages)
}
/** Scan all system packages. */
suspend fun scanSystem(context: Context, config: BackupConfig): List<AppInfo> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list packages -s")
suspend fun scanSystem(context: Context, config: BackupConfig, userId: Int = 0): List<AppInfo> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list packages -s --user $userId")
if (!result.isSuccess) return@withContext emptyList()
val systemWhitelist = config.system.toSet()
@@ -48,14 +62,12 @@ object AppScanner {
.map { it.removePrefix("package:").trim() }
.filter { it.isNotEmpty() }
.filter { pkg ->
// Allow if in system whitelist or data whitelist
pkg in systemWhitelist || pkg in dataWhitelist
}
.filter { pkg ->
// Exclude if in blacklist (when blacklistMode=1, full ignore)
if (config.blacklistMode == 1) pkg !in blacklist else true
}
.map { AppInfo(packageName = it, isSystem = true) }
.map { AppInfo(packageName = it, isSystem = true, userId = userId) }
resolveLabels(context, packages)
}
@@ -112,7 +124,69 @@ object AppScanner {
val result = RootShell.exec("pidof '${packageName.shellEscape()}'")
result.output.isNotBlank()
}
/** Check if an app has keystore entries (critical — keystore keys can be lost on backup). */
suspend fun hasKeystore(packageName: String): Boolean = withContext(Dispatchers.IO) {
// Resolve the app's UID first
val uidResult = RootShell.exec("dumpsys package '$packageName' | grep 'userId=' | head -1")
val uid = uidResult.output
.substringAfter("userId=", "")
.substringBefore(" ")
.substringBefore(",")
.trim()
.toIntOrNull() ?: return@withContext false
// keystore_cli_v2 list as app UID — more than 1 line means has keystore entries
val ksResult = RootShell.exec("su $uid -c 'keystore_cli_v2 list' 2>/dev/null")
ksResult.output.lines().count { it.isNotBlank() } > 1
}
/** Enumerate all user profiles on the device for multi-user support. */
suspend fun enumerateUsers(): List<Pair<Int, String>> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list users")
if (!result.isSuccess) return@withContext listOf(0 to "Owner")
result.output.lines()
.filter { it.contains("UserInfo") }
.mapNotNull { line ->
val id = line.substringBefore(":").trim().toIntOrNull()
val name = line.substringAfter(":").substringBefore(":").trim()
if (id != null) id to name else null
}
}
/** Extract and save an app's icon to the given directory. */
suspend fun extractIcon(packageName: String, destDir: java.io.File, userId: Int = 0): String? = withContext(Dispatchers.IO) {
// Try snapshot cache first
val snapshotDir = "/data/system_ce/$userId/snapshots/$packageName"
val snapshotResult = RootShell.exec("ls '$snapshotDir/' 2>/dev/null | head -1")
if (snapshotResult.isSuccess && snapshotResult.output.isNotBlank()) {
val iconName = snapshotResult.output.trim()
val iconFile = java.io.File(destDir, "app_icon.png")
val copyResult = RootShell.exec("cp '${snapshotDir}/${iconName.shellEscape()}' '${iconFile.absolutePath.shellEscape()}' 2>/dev/null")
if (copyResult.isSuccess && iconFile.exists()) {
return@withContext iconFile.absolutePath
}
}
// Fallback: extract from APK using aapt
val apkPaths = getApkPaths(packageName)
if (apkPaths.isNotEmpty()) {
val primaryApk = apkPaths.first()
val badgeResult = RootShell.exec("aapt d badging '$primaryApk' 2>/dev/null | grep '^application:.*icon=' | head -1")
if (badgeResult.isSuccess) {
val iconPath = badgeResult.output
.substringAfter("icon='")
.substringBefore("'")
.takeIf { it.isNotBlank() }
if (iconPath != null) {
// The icon path is relative inside the APK, extract using aapt
val iconFile = java.io.File(destDir, "app_icon.png")
RootShell.exec("aapt d raw '$primaryApk' '$iconPath' > '${iconFile.absolutePath.shellEscape()}' 2>/dev/null")
if (iconFile.exists()) {
return@withContext iconFile.absolutePath
}
}
}
}
null
}
/** Apply appList.txt-style filters. Lines starting with # are ignored, ! means apk-only. */
fun parseAppList(content: String): List<Pair<String, Boolean>> {
return content.lines()

View File

@@ -51,6 +51,7 @@ object BackupOperation {
* @param onProgress callback for UI updates
*/
suspend fun backupApps(
context: android.content.Context,
apps: List<AppInfo>,
config: BackupConfig,
outputDir: File,
@@ -63,6 +64,7 @@ object BackupOperation {
// Create backup structure
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
backupRoot.mkdirs()
LogUtil.i(TAG, "backupApps: starting backup of ${apps.size} apps to ${backupRoot.absolutePath}")
// Write app list
val appListFile = File(backupRoot, "appList.txt")
@@ -102,10 +104,16 @@ object BackupOperation {
return@withPermit
}
// 1.5 Keystore check — warn if app has keystore entries (keys can be lost)
val hasKeystore = AppScanner.hasKeystore(app.packageName)
if (hasKeystore) {
emit(BackupProgress(index + 1, apps.size, app.packageName, "data", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失"))
}
// 2. Backup user data (if configured)
if (config.backupMode == 1 && config.backupUserData == 1) {
emit(BackupProgress(index + 1, apps.size, app.packageName, "data", "正在备份数据…"))
if (!backupUserData(app.packageName, appDir, userId, config.compressionMethod)) {
if (!backupUserData(context, app.packageName, appDir, userId, config.compressionMethod)) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "数据备份失败"))
return@withPermit
@@ -129,6 +137,12 @@ object BackupOperation {
emit(BackupProgress(index + 1, apps.size, app.packageName, "ssaid", "正在备份 SSAID…"))
backupSsaid(app.packageName, appDir, userId)
// 4.5 Backup app icon
val iconPath = AppScanner.extractIcon(app.packageName, appDir, app.userId)
if (iconPath != null) {
Log.d(TAG, "backupApps: saved icon for ${app.packageName} -> $iconPath")
}
// 5. Backup runtime permissions
backupPermissions(app.packageName, appDir)
@@ -136,16 +150,22 @@ object BackupOperation {
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "完成"))
}
}
}
}
}
val elapsed = System.currentTimeMillis() - startTime
RootShell.exec("chmod -R 0755 '${backupRoot.absolutePath}'")
val successCount = successAtomic.get()
val failCount = failAtomic.get()
val skippedCount = skippedAtomic.get()
LogUtil.i(TAG, "backupApps: completed — success=$successCount fail=$failCount skipped=$skippedCount elapsed=${elapsed}ms")
BackupResult(
successCount = successAtomic.get(),
failCount = failAtomic.get(),
skippedCount = skippedAtomic.get(),
successCount = successCount,
failCount = failCount,
skippedCount = skippedCount,
outputDir = backupRoot.absolutePath,
elapsedMs = elapsed
)
@@ -153,6 +173,7 @@ object BackupOperation {
private suspend fun backupUserData(
context: android.content.Context,
packageName: String,
appDir: File,
userId: String,
@@ -160,101 +181,117 @@ object BackupOperation {
): Boolean {
val pkgEsc = packageName.shellEscape()
val outputFile = "${appDir.absolutePath.shellEscape()}/${pkgEsc}_data.tar"
val isZstd = compression == "zstd"
// Resolve bundled binary paths (fall back to system PATH if not bundled)
val bundledTar = BinaryResolver.tarPath(context)
val tarCmd = bundledTar ?: "tar"
var isZstd = compression == "zstd"
val bundledZstd = if (isZstd) BinaryResolver.zstdPath(context) else null
val zstdCmd = bundledZstd ?: "zstd"
if (isZstd && bundledZstd == null) {
val zstdCheck = RootShell.exec("$zstdCmd --version 2>/dev/null")
if (!zstdCheck.isSuccess) {
Log.w(TAG, "backupUserData: zstd not available, falling back to gzip")
isZstd = false
}
}
val archiveExt = if (isZstd) ".zst" else ".gz"
val archiveRaw = File(appDir, "${packageName}_data.tar$archiveExt")
Log.d(TAG, "backupUserData: $packageName checking dirs")
Log.d(TAG, "backupUserData: $packageName checking dirs (tar=$tarCmd zstd=$zstdCmd)")
val rawPkg = packageName
val dataPaths = listOf("/data/data/$rawPkg", "/data/user_de/$userId/$rawPkg")
val dataExcludes = listOf(".ota", "cache", "lib", "code_cache", "no_backup")
// 1. Try direct paths (app's mount namespace)
val dirs = dataPaths.filter { RootShell.exec("test -d $it").isSuccess }.toMutableList()
var result: RootShell.ShellResult? = null
// 1. Try direct paths after nsenter namespace switch
var archiveCreated = false
var result: RootShell.ShellResult? = null
val dirs = dataPaths.filter { RootShell.exec("test -d $it").isSuccess }.toMutableList()
if (dirs.isNotEmpty()) {
Log.d(TAG, "backupUserData: $packageName test -d found dirs=$dirs")
result = runTar(dirs, outputFile, isZstd)
archiveCreated = result?.isSuccess == true
result = runTar(dirs, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
Log.d(TAG, "backupUserData: $packageName step1 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
} else {
// 2. Try tar directly on direct paths (may fail in isolated namespace)
Log.d(TAG, "backupUserData: $packageName test -d all failed, trying tar directly")
result = runTar(dataPaths, outputFile, isZstd)
archiveCreated = result?.isSuccess == true || (archiveRaw.exists() && archiveRaw.length() > 0)
result = runTar(dataPaths, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
Log.d(TAG, "backupUserData: $packageName step2 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
}
// 3. If still failed, try via /proc/1/root (global mount namespace)
// Use cd to avoid tar packing the /proc/1/root/ prefix into the archive.
// 3. Fallback via /proc/1/root (global mount namespace)
if (!archiveCreated) {
Log.w(TAG, "backupUserData: $packageName direct access failed, trying via /proc/1/root (global namespace)")
val globalRelPaths = dataPaths.map { it.removePrefix("/") } // e.g. "data/data/tv.danmaku.bili"
val excludeArgs = "--exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup'"
Log.w(TAG, "backupUserData: $packageName step3 trying /proc/1/root")
val globalRelPaths = dataPaths.map { it.removePrefix("/") }
val globalCmd = if (isZstd) {
"cd /proc/1/root && tar $excludeArgs -cf - ${globalRelPaths.joinToString(" ")} 2>/dev/null | zstd -T0 -o '$outputFile.zst'"
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(" ")} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
} else {
"cd /proc/1/root && tar $excludeArgs -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ")} 2>/dev/null"
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ")} 2>/dev/null"
}
result = RootShell.exec(globalCmd)
archiveCreated = result?.isSuccess == true || (archiveRaw.exists() && archiveRaw.length() > 0)
}
// 4. Last resort: try su -Z (Magisk SELinux context switch) — on Magisk 30+,
// the app's su context (untrusted_app) cannot see other apps' /data/data/.
// Switching to u:r:magisk:s0 lifts the SELinux restriction.
if (!archiveCreated) {
Log.w(TAG, "backupUserData: $packageName /proc/1/root failed, trying su -Z magisk context")
val excludeArgs = "--exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup'"
val dirList = dataPaths.joinToString(" ")
val rawOut = appDir.absolutePath + "/" + packageName + "_data.tar"
val innerCmd = if (isZstd) {
"tar $excludeArgs -cf - $dirList 2>/dev/null | zstd -T0 -o '${rawOut}.zst'"
} else {
"tar $excludeArgs -czf '${rawOut}.gz' $dirList 2>/dev/null"
}
result = RootShell.exec("su -Z u:r:magisk:s0 -c \"$innerCmd\" 2>/dev/null")
archiveCreated = result?.isSuccess == true || (archiveRaw.exists() && archiveRaw.length() > 0)
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
Log.d(TAG, "backupUserData: $packageName step3 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
}
if (!archiveCreated) {
Log.w(TAG, "backupUserData: $packageName all methods failed — no data dirs to backup (or inaccessible)")
Log.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)")
return true
}
// Verify integrity
// Verify compression integrity
val verifyOk = if (isZstd) {
RootShell.exec("zstd -t '$outputFile.zst' 2>/dev/null").isSuccess
RootShell.exec("$zstdCmd -t '$outputFile.zst' 2>/dev/null").isSuccess
} else {
RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess
}
if (!verifyOk) {
Log.e(TAG, "backupUserData: $packageName integrity check FAILED")
return false
}
return verifyOk
// Validate tar archive structure (Android-DataBackup Tar.test() pattern)
val tarValidateOk = if (isZstd) {
RootShell.exec("$zstdCmd -d -c '$outputFile.zst' 2>/dev/null | tar -tf - > /dev/null 2>&1").isSuccess
} else {
RootShell.exec("tar -tf '$outputFile.gz' > /dev/null 2>&1").isSuccess
}
if (!tarValidateOk) {
Log.e(TAG, "backupUserData: $packageName tar archive structure validation FAILED")
return false
}
return true
}
/** Run tar for given paths, building the appropriate zstd/gzip command. */
private suspend fun runTar(
dirs: List<String>,
outputFile: String,
isZstd: Boolean
isZstd: Boolean,
tarCmd: String = "tar",
zstdCmd: String = "zstd",
excludes: List<String> = emptyList()
): RootShell.ShellResult {
val excludeArgs = "--exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup'"
val dirList = dirs.joinToString(" ")
val excludeArgs = if (excludes.isNotEmpty()) {
excludes.joinToString(" ") { "--exclude='$it'" }
} else ""
return if (isZstd) {
RootShell.exec("tar $excludeArgs -cf - $dirList 2>/dev/null | zstd -T0 -o '$outputFile.zst'")
RootShell.exec("$tarCmd -cf - $excludeArgs ${dirs.joinToString(" ")} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'")
} else {
RootShell.exec("tar $excludeArgs -czf '$outputFile.gz' $dirList 2>/dev/null")
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ")} 2>/dev/null")
}
}
private suspend fun backupObb(packageName: String, appDir: File, compression: String): Boolean {
val obbDir = "/storage/emulated/0/Android/obb/${packageName.shellEscape()}"
val escapedAppDir = appDir.absolutePath.shellEscape()
val escapedPkg = packageName.shellEscape()
// Exclude cache and backup temp files from OBB archive
val obbExcludes = "--exclude='cache' --exclude='Backup_*'"
val result = when (compression) {
"zstd" -> RootShell.exec("tar -cf - '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'")
else -> RootShell.exec("tar -czf '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
"zstd" -> RootShell.exec("tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'")
else -> RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
}
if (!result.isSuccess) {
Log.e(TAG, "Failed to backup OBB for $packageName: exit=${result.exitCode} err=${result.error}")
@@ -266,14 +303,30 @@ object BackupOperation {
if (!verificationOk) {
Log.e(TAG, "OBB archive integrity check FAILED for $packageName")
}
return verificationOk
// Validate OBB tar structure
val tarListCmd = if (compression == "zstd") "zstd -d -c '$archive' 2>/dev/null | tar -tf - > /dev/null 2>&1" else "tar -tf '$archive' > /dev/null 2>&1"
val tarOk = RootShell.exec(tarListCmd).isSuccess
if (!tarOk) {
Log.e(TAG, "OBB tar structure validation FAILED for $packageName")
}
return verificationOk && tarOk
}
private suspend fun backupSsaid(packageName: String, appDir: File, userId: String) {
val ssaidFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
val result = RootShell.exec("grep '${packageName.shellEscape()}' '$ssaidFile' 2>/dev/null")
if (result.output.isNotBlank()) {
File(appDir, "ssaid.txt").writeText(result.output)
// Parse XML value attribute for this package's SSAID entry
val result = RootShell.exec("cat '$ssaidFile' 2>/dev/null")
if (!result.isSuccess || result.output.isBlank()) return
val ssaidLine = result.output.lines().firstOrNull { line ->
line.contains("packageName=\"$packageName\"") || line.contains("packageName='$packageName'")
}
val value = ssaidLine
?.substringAfter("value=\"")
?.substringBefore("\"")
?.takeIf { it.isNotBlank() }
if (value != null) {
File(appDir, "ssaid.txt").writeText(value)
Log.d(TAG, "backupSsaid: backed up SSAID for $packageName = $value")
}
}

View File

@@ -0,0 +1,73 @@
package com.example.androidbackupgui.backup
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
/**
* Foreground service to keep the process alive during long backup/restore operations.
* Prevents Android from killing the app during extended operations.
*/
class BackupService : Service() {
companion object {
const val CHANNEL_ID = "backup_service_channel"
const val NOTIFICATION_ID = 1001
const val ACTION_START_BACKUP = "com.example.androidbackupgui.action.START_BACKUP"
const val ACTION_STOP_BACKUP = "com.example.androidbackupgui.action.STOP_BACKUP"
const val EXTRA_STATUS_TEXT = "status_text"
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START_BACKUP -> {
val statusText = intent.getStringExtra(EXTRA_STATUS_TEXT) ?: "正在备份…"
val notification = createNotification(statusText)
startForeground(NOTIFICATION_ID, notification)
}
ACTION_STOP_BACKUP -> {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"备份服务",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "后台备份任务持续运行通知"
setShowBadge(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
private fun createNotification(text: String): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Android Backup")
.setContentText(text)
.setSmallIcon(android.R.drawable.ic_menu_upload)
.setOngoing(true)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
}

View File

@@ -0,0 +1,49 @@
package com.example.androidbackupgui.backup
import android.content.Context
import android.util.Log
import java.io.File
/**
* Resolves paths to binaries bundled in jniLibs.
* Android's PackageManager extracts lib*.so from jniLibs to nativeLibraryDir.
* We copy them to app-private dir (writable, executable) for ProcessBuilder use.
*/
object BinaryResolver {
private const val TAG = "BinaryResolver"
private val cacheTar = ResolveCache()
private val cacheZstd = ResolveCache()
private class ResolveCache {
var initialized = false
var path: String? = null
}
fun tarPath(context: Context): String? = resolve(context, "libtar_bin.so", "tar_bin", cacheTar)
fun zstdPath(context: Context): String? = resolve(context, "libzstd_bin.so", "zstd_bin", cacheZstd)
private fun resolve(context: Context, libName: String, destName: String, cache: ResolveCache): String? {
if (cache.initialized) return cache.path
val nativeLibDir = context.applicationInfo.nativeLibraryDir
val source = File(nativeLibDir, libName)
if (!source.isFile) {
Log.e(TAG, "$libName NOT FOUND at ${source.absolutePath}")
cache.initialized = true
cache.path = null
return null
}
val dest = File(context.filesDir, "bin/$destName")
if (!dest.exists() || dest.length() != source.length() || !dest.canExecute()) {
dest.parentFile?.mkdirs()
if (dest.exists()) dest.delete()
source.inputStream().use { src -> dest.outputStream().use { out -> src.copyTo(out) } }
dest.setExecutable(true)
}
val result = dest.absolutePath
Log.i(TAG, "ready: $libName -> $result (${dest.length()} bytes) canExec=${dest.canExecute()}")
cache.path = result
cache.initialized = true
return result
}
}

View File

@@ -0,0 +1,85 @@
package com.example.androidbackupgui.backup
import android.util.Log
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
/**
* File-based logger with rotation support.
* Writes logs to [baseDir]/logs/YYYY-MM-dd.log, keeping up to [maxDays] days.
* Also dispatches to Android Logcat for real-time visibility.
*/
object LogUtil {
private const val TAG = "LogUtil"
private const val MAX_DAYS = 7
private var baseDir: File? = null
private val executor = Executors.newSingleThreadExecutor()
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
private val timestampFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
fun init(baseDir: File) {
this.baseDir = baseDir
executor.execute { rotateLogs() }
}
fun i(tag: String, message: String) {
Log.i(tag, message)
writeLog("I", tag, message)
}
fun w(tag: String, message: String) {
Log.w(tag, message)
writeLog("W", tag, message)
}
fun e(tag: String, message: String) {
Log.e(tag, message)
writeLog("E", tag, message)
}
private fun writeLog(level: String, tag: String, message: String) {
val dir = baseDir ?: return
executor.execute {
try {
val today = dateFormat.format(Date())
val logFile = File(File(dir, "logs"), "$today.log")
logFile.parentFile?.mkdirs()
val timestamp = timestampFormat.format(Date())
val line = "$timestamp $level/$tag: $message\n"
logFile.appendText(line)
} catch (_: Exception) {
// Silently fail — logging should never crash the app
}
}
}
private fun rotateLogs() {
val dir = baseDir ?: return
val logDir = File(dir, "logs")
if (!logDir.exists()) return
val cutoff = System.currentTimeMillis() - MAX_DAYS * 24L * 60 * 60 * 1000
logDir.listFiles()
?.filter { it.name.endsWith(".log") }
?.forEach { file ->
if (file.lastModified() < cutoff) {
file.delete()
}
}
}
/** Get all log files sorted by name (date ascending). */
fun getLogFiles(): List<File> {
val dir = baseDir ?: return emptyList()
val logDir = File(dir, "logs")
return logDir.listFiles()
?.filter { it.name.endsWith(".log") }
?.sortedBy { it.name }
?: emptyList()
}
}

View File

@@ -69,6 +69,7 @@ object RestoreOperation {
} else {
allPackages
}
LogUtil.i(TAG, "restoreApps: starting restore of ${packages.size} packages from ${backupDir.absolutePath}")
val successAtomic = AtomicInteger(0)
val failAtomic = AtomicInteger(0)
@@ -125,7 +126,10 @@ object RestoreOperation {
}
val elapsed = System.currentTimeMillis() - startTime
RestoreResult(successAtomic.get(), failAtomic.get(), elapsed)
val successCount = successAtomic.get()
val failCount = failAtomic.get()
LogUtil.i(TAG, "restoreApps: completed — success=$successCount fail=$failCount elapsed=${elapsed}ms")
RestoreResult(successCount, failCount, elapsed)
}
private suspend fun installApk(appDir: File): Boolean {
@@ -251,19 +255,36 @@ object RestoreOperation {
val ssaidFile = File(appDir, "ssaid.txt")
if (!ssaidFile.exists()) return
val ssaidLine = ssaidFile.readText().trim()
if (ssaidLine.isBlank()) return
val ssaidValue = ssaidFile.readText().trim()
if (ssaidValue.isBlank()) return
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
val pkgEsc = packageName.shellEscape()
val ssaidEsc = ssaidLine.shellEscape()
// Resolve the app's UID
val uidResult = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep 'userId=' | head -1")
val uid = uidResult.output
.substringAfter("userId=", "")
.substringBefore(" ")
.substringBefore(",")
.trim()
.toIntOrNull()
// Remove existing entry for this package, insert new one before </settings>
RootShell.exec(
"grep -v '${pkgEsc}' '$targetFile' > '$targetFile.tmp' && " +
"sed -i '\$ i ${ssaidEsc}' '$targetFile.tmp' && " +
"mv '$targetFile.tmp' '$targetFile'"
)
if (uid != null) {
// Use settings put secure to set SSAID (more reliable than XML manipulation)
val result = RootShell.exec("settings put secure ssaid_$uid '$ssaidValue'")
if (result.isSuccess) {
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName (uid=$uid)")
} else {
Log.w(TAG, "restoreSsaid: failed to set SSAID for $packageName: ${result.error}")
}
} else {
Log.w(TAG, "restoreSsaid: could not resolve UID for $packageName, falling back to XML edit")
// Fallback: edit settings_ssaid.xml directly
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
RootShell.exec(
"grep -v '${packageName.shellEscape()}' '$targetFile' > '$targetFile.tmp' && " +
"sed -i '\$ i ${ssaidValue.shellEscape()}' '$targetFile.tmp' && " +
"mv '$targetFile.tmp' '$targetFile'"
)
}
}
private suspend fun restorePermissions(packageName: String, appDir: File) {

View File

@@ -34,22 +34,37 @@ object RootShell {
}
/**
* Trigger root shell pre-initialization.
* Returns true if root is available.
* Note: Shell.cmd() also auto-initializes on first use, so this is optional.
* libsu shell initializer: enter global mount namespace via nsenter.
* Preserves the original PATH so that tar/zstd (from Termux etc.) remain accessible.
* Ref: DataBackup (XayahSuSuSu) uses the same nsenter pattern.
*/
private class GlobalNamespaceInitializer : Shell.Initializer() {
override fun onInit(context: android.content.Context, shell: Shell): Boolean {
shell.newJob()
.add("nsenter --mount=/proc/1/ns/mnt sh")
.add("set -o pipefail")
.exec()
return true
}
}
/** Call once at app startup to configure libsu. */
fun configure() {
Shell.enableVerboseLogging = true
Shell.setDefaultBuilder(
Shell.Builder.create()
.setFlags(Shell.FLAG_MOUNT_MASTER)
.setInitializers(GlobalNamespaceInitializer::class.java)
.setTimeout(30)
)
}
suspend fun ensureSession(): Boolean = withContext(Dispatchers.IO) {
try {
Shell.getShell().isRoot
} catch (_: Exception) { false }
}
/**
* Execute a shell command and return the result.
* libsu internally runs via `su`, compatible with Magisk/KernelSU/APatch.
* Commands are passed verbatim to `su -c`, so pipes and redirects work normally.
* Timeout is enforced via structured coroutine cancellation.
*/
suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult =
withContext(Dispatchers.IO) {
try {

View File

@@ -1,9 +1,12 @@
package com.example.androidbackupgui.ui
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
@@ -11,6 +14,7 @@ import com.example.androidbackupgui.backup.AppInfo
import com.example.androidbackupgui.backup.AppScanner
import com.example.androidbackupgui.backup.BackupConfig
import com.example.androidbackupgui.backup.BackupOperation
import com.example.androidbackupgui.backup.BackupService
import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.ResticWrapper
import com.example.androidbackupgui.backup.WifiManager
@@ -28,7 +32,14 @@ class BackupFragment : Fragment() {
private val binding get() = _binding!!
private var apps: List<AppInfo> = emptyList()
private var selectedApps = mutableSetOf<String>()
private var sortedApps: List<AppInfo> = emptyList()
private lateinit var config: BackupConfig
private var selectedUserId: Int = 0
private var userList: List<Pair<Int, String>> = listOf(0 to "Owner")
private var sortMode: SortMode = SortMode.NAME_ASC
private var showSystemApps: Boolean = false
private enum class SortMode { NAME_ASC, SIZE_DESC }
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
@@ -47,11 +58,51 @@ class BackupFragment : Fragment() {
binding.scanButton.setOnClickListener { scanApps() }
binding.backupButton.setOnClickListener { startBackup() }
// Sort/filter controls
binding.sortAZButton.setOnClickListener {
sortMode = SortMode.NAME_ASC
applySortFilter()
}
binding.sortSizeButton.setOnClickListener {
sortMode = SortMode.SIZE_DESC
applySortFilter()
}
binding.selectAllButton.setOnClickListener {
selectedApps.addAll(apps.map { it.packageName })
applySortFilter()
}
binding.deselectAllButton.setOnClickListener {
selectedApps.clear()
applySortFilter()
}
binding.showSystemSwitch.setOnCheckedChangeListener { _, checked ->
showSystemApps = checked
applySortFilter()
}
// Load user profiles and setup dropdown
loadUsers()
}
private fun loadUsers() {
viewLifecycleOwner.lifecycleScope.launch {
userList = AppScanner.enumerateUsers()
val names = userList.map { (id, name) -> "$name (ID: $id)" }
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.userSelector.adapter = adapter
binding.userSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedUserId = userList.getOrNull(position)?.first ?: 0
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
}
override fun onResume() {
super.onResume()
// Re-read config so changes from ConfigFragment take effect immediately
val configFile = File(requireContext().filesDir, "backup_settings.conf")
config = BackupConfig.fromFile(configFile)
}
@@ -63,9 +114,9 @@ class BackupFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch {
val ctx = requireContext()
val thirdParty = AppScanner.scanThirdParty(ctx)
val system = AppScanner.scanSystem(ctx, config)
apps = thirdParty + system
val thirdParty = AppScanner.scanThirdParty(ctx, userId = selectedUserId)
val system = AppScanner.scanSystem(ctx, config, userId = selectedUserId)
apps = if (showSystemApps) thirdParty + system else thirdParty
selectedApps.clear()
selectedApps.addAll(apps.map { it.packageName })
@@ -73,137 +124,163 @@ class BackupFragment : Fragment() {
binding.backupButton.isEnabled = apps.isNotEmpty()
setRunning(false)
setupAppList()
applySortFilter()
}
}
private fun applySortFilter() {
var filtered = if (showSystemApps) apps else apps.filter { !it.isSystem }
filtered = when (sortMode) {
SortMode.NAME_ASC -> filtered.sortedBy { it.label.lowercase(Locale.US) }
SortMode.SIZE_DESC -> filtered.sortedByDescending { it.backupSize }
}
sortedApps = filtered
setupAppList()
binding.statusText.text = "已选择 ${selectedApps.size}/${sortedApps.size} 个应用"
}
private fun setupAppList() {
binding.appList.adapter = PackageListAdapter(apps, selectedApps) { pkg, checked ->
val displayApps = sortedApps.ifEmpty { apps }
binding.appList.adapter = PackageListAdapter(displayApps, selectedApps) { pkg, checked ->
if (checked) selectedApps.add(pkg) else selectedApps.remove(pkg)
binding.statusText.text = "已选择 ${selectedApps.size}/${apps.size} 个应用"
binding.statusText.text = "已选择 ${selectedApps.size}/${displayApps.size} 个应用"
}
}
private fun startBackup() {
val toBackup = apps.filter { it.packageName in selectedApps }
if (toBackup.isEmpty()) return
setRunning(true)
binding.backupButton.isEnabled = false
binding.scanButton.isEnabled = false
// Start foreground service to keep process alive
val serviceIntent = Intent(requireContext(), BackupService::class.java)
serviceIntent.action = BackupService.ACTION_START_BACKUP
serviceIntent.putExtra(BackupService.EXTRA_STATUS_TEXT, "正在备份 ${toBackup.size} 个应用…")
try {
requireContext().startForegroundService(serviceIntent)
} catch (_: Exception) {}
viewLifecycleOwner.lifecycleScope.launch {
val outputDir = File(config.outputPath.ifEmpty {
requireContext().filesDir.absolutePath
})
WifiManager.backup(outputDir)
val result = BackupOperation.backupApps(
apps = toBackup,
config = config,
outputDir = outputDir,
onProgress = { progress ->
val label = toBackup.find { it.packageName == progress.packageName }?.label
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
binding.statusText.text =
"[${progress.current}/${progress.total}] $name: ${progress.message}"
}
)
// If restic is enabled, snapshot the backup to a restic repository
var resticSummary: ResticWrapper.BackupSummary? = null
var resticError: String? = null
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
val binaryPath = ResticBinary.prepare(requireContext())
if (binaryPath != null) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
ResticWrapper.backendDomain = config.resticBackendDomain
// For local repos, verify init before attempting backup
if (config.resticBackend == "local") {
if (!File(config.resticRepo, "config").exists()) {
binding.statusText.text = "restic 本地仓库未初始化,请先在设置中初始化"
setRunning(false)
binding.scanButton.isEnabled = true
return@launch
}
try {
val outputDir = File(config.outputPath.ifEmpty {
requireContext().filesDir.absolutePath
})
WifiManager.backup(outputDir)
val result = BackupOperation.backupApps(
context = requireContext(),
apps = toBackup,
config = config,
outputDir = outputDir,
userId = selectedUserId.toString(),
onProgress = { progress ->
val label = toBackup.find { it.packageName == progress.packageName }?.label
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
binding.statusText.text =
"[${progress.current}/${progress.total}] $name: ${progress.message}"
}
binding.statusText.text = "正在写入 restic 去重仓库…"
val resticResult = ResticWrapper.backup(
repoPath = config.resticRepo,
password = config.resticPassword,
paths = listOf(result.outputDir),
tags = listOf("backup_${System.currentTimeMillis() / 1000}"),
hostname = "android-backup-gui",
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onSyncProgress = { progress: RemoteTransport.TransferProgress ->
withContext(Dispatchers.Main) {
when (progress.phase) {
"list", "download", "upload", "delete_stale" ->
binding.statusText.text = "同步中: ${progress.current}/${progress.total} 个文件"
)
// If restic is enabled, snapshot to repository
var resticSummary: ResticWrapper.BackupSummary? = null
var resticError: String? = null
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
val binaryPath = ResticBinary.prepare(requireContext())
if (binaryPath != null) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
ResticWrapper.backendDomain = config.resticBackendDomain
if (config.resticBackend == "local") {
if (!File(config.resticRepo, "config").exists()) {
binding.statusText.text = "restic 本地仓库未初始化,请先在设置中初始化"
return@launch
}
}
binding.statusText.text = "正在写入 restic 去重仓库…"
val resticResult = ResticWrapper.backup(
repoPath = config.resticRepo,
password = config.resticPassword,
paths = listOf(result.outputDir),
tags = listOf("backup_${System.currentTimeMillis() / 1000}"),
hostname = "android-backup-gui",
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onSyncProgress = { progress: RemoteTransport.TransferProgress ->
withContext(Dispatchers.Main) {
when (progress.phase) {
"list", "download", "upload", "delete_stale" ->
binding.statusText.text = "同步中: ${progress.current}/${progress.total} 个文件"
}
}
},
onByteSyncProgress = { progress ->
withContext(Dispatchers.Main) {
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
binding.progressBar.progress = progress.bytesTransferred.toInt()
binding.statusText.text = "同步中: ${progress.currentFile}\n" +
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}"
}
},
onProgress = { progress ->
if (progress.messageType == "status") {
binding.statusText.text = "去重仓库: %.0f%% (%d/%d 个文件)".format(
progress.percentDone * 100,
progress.filesDone,
progress.totalFiles
)
}
}
},
onByteSyncProgress = { progress ->
withContext(Dispatchers.Main) {
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
binding.progressBar.progress = progress.bytesTransferred.toInt()
binding.statusText.text = "同步中: ${progress.currentFile}\n" +
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}"
)
resticResult.fold(
onSuccess = { resticSummary = it },
onFailure = { e ->
resticError = e.message
binding.statusText.text = "restic 快照失败: ${e.message}"
}
},
onProgress = { progress ->
if (progress.messageType == "status") {
binding.statusText.text = "去重仓库: %.0f%% (%d/%d 个文件)".format(
progress.percentDone * 100,
progress.filesDone,
progress.totalFiles
)
}
}
)
resticResult.fold(
onSuccess = { resticSummary = it },
onFailure = { e ->
resticError = e.message
binding.statusText.text = "restic 快照失败: ${e.message}"
}
)
)
}
}
}
binding.statusText.text = buildString {
appendLine("备份完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
appendLine("耗时: ${result.elapsedMs / 1000}")
appendLine("输出: ${result.outputDir}")
if (resticSummary != null) {
appendLine()
appendLine("── Restic 快照 ──")
appendLine("ID: ${resticSummary!!.snapshotId.take(8)}")
appendLine("新增: ${resticSummary!!.dataAdded / 1024 / 1024} MB")
appendLine("文件: ${resticSummary!!.totalFilesProcessed}")
} else if (resticError != null) {
appendLine()
appendLine("── Restic 错误 ──")
appendLine(resticError!!)
binding.statusText.text = buildString {
appendLine("备份完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
appendLine("耗时: ${result.elapsedMs / 1000}")
appendLine("输出: ${result.outputDir}")
if (resticSummary != null) {
appendLine()
appendLine("── Restic 快照 ──")
appendLine("ID: ${resticSummary!!.snapshotId.take(8)}")
appendLine("新增: ${resticSummary!!.dataAdded / 1024 / 1024} MB")
appendLine("文件: ${resticSummary!!.totalFilesProcessed}")
} else if (resticError != null) {
appendLine()
appendLine("── Restic 错误 ──")
appendLine(resticError!!)
}
}
} finally {
setRunning(false)
binding.scanButton.isEnabled = true
// Stop foreground service
try {
val stopIntent = Intent(requireContext(), BackupService::class.java)
stopIntent.action = BackupService.ACTION_STOP_BACKUP
requireContext().startService(stopIntent)
} catch (_: Exception) {}
}
setRunning(false)
binding.scanButton.isEnabled = true
}
}
private fun formatSize(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = arrayOf("KB", "MB", "GB", "TB")
val exp = (63 - bytes.countLeadingZeroBits()) / 10
val value = bytes.toDouble() / (1L shl (exp * 10))
return "%.1f %s".format(Locale.US, value, units[exp - 1].coerceAtMost(units.last()))
if (bytes <= 0) return "0 B"
val units = arrayOf("B", "KB", "MB", "GB")
val digitGroups = (Math.log10(bytes.toDouble()) / Math.log10(1024.0)).toInt()
return String.format(Locale.US, "%.1f %s", bytes / Math.pow(1024.0, digitGroups.toDouble()), units[digitGroups])
}
private fun setRunning(running: Boolean) {
@@ -211,10 +288,11 @@ class BackupFragment : Fragment() {
}
override fun onDestroyView() {
super.onDestroyView()
// Cleanup restic temp files when leaving the fragment
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
ResticWrapper.cleanup()
}
super.onDestroyView()
_binding = null
}
}

View File

@@ -4,6 +4,8 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment
import android.app.AlertDialog
import androidx.lifecycle.lifecycleScope
@@ -35,6 +37,8 @@ class RestoreFragment : Fragment() {
private var selectedPackages = mutableSetOf<String>()
private var resticConfig: BackupConfig? = null
private var selectedSnapshot: ResticWrapper.ResticSnapshot? = null
private var selectedUserId: Int = 0
private var userList: List<Pair<Int, String>> = listOf(0 to "Owner")
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
@@ -45,6 +49,7 @@ class RestoreFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.appList.layoutManager = LinearLayoutManager(requireContext())
// Load restic config
@@ -66,6 +71,25 @@ class RestoreFragment : Fragment() {
binding.selectDirButton.setOnClickListener { selectBackupDir() }
binding.selectResticButton.setOnClickListener { selectResticSnapshot() }
binding.restoreButton.setOnClickListener { startRestore() }
// Load user profiles
loadUsers()
}
private fun loadUsers() {
viewLifecycleOwner.lifecycleScope.launch {
userList = AppScanner.enumerateUsers()
val names = userList.map { (id, name) -> "$name (ID: $id)" }
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.userSelector.adapter = adapter
binding.userSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedUserId = userList.getOrNull(position)?.first ?: 0
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
}
override fun onResume() {
@@ -126,7 +150,7 @@ class RestoreFragment : Fragment() {
private fun selectResticSnapshot() {
val config = resticConfig ?: return
setRunning(true)
binding.statusText.text = "正在读取 restic 快照列表"
binding.statusText.text = "正在同步远程仓库到本地"
viewLifecycleOwner.lifecycleScope.launch {
val snapshotsResult = ResticWrapper.listSnapshots(
@@ -135,7 +159,13 @@ class RestoreFragment : Fragment() {
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare
backendShare = config.resticBackendShare,
onSyncProgress = { p ->
binding.statusText.text = "同步中: ${p.current}/${p.total} [${p.currentFile}]"
},
onByteSyncProgress = { bp ->
binding.statusText.text = "下载中: ${bp.bytesTransferred / 1024 / 1024} MB / ${bp.totalBytes / 1024 / 1024} MB"
}
)
if (snapshotsResult.isFailure) {
binding.statusText.text = "读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}"
@@ -295,6 +325,7 @@ class RestoreFragment : Fragment() {
val r = RestoreOperation.restoreApps(
backupDir = restoredBackupDir,
userId = selectedUserId.toString(),
filterPkgs = selectedPackages,
onProgress = { progress ->
val label = appInfos.find { it.packageName == progress.packageName }?.label
@@ -314,6 +345,7 @@ class RestoreFragment : Fragment() {
val dir = backupDir ?: return@launch
val r = RestoreOperation.restoreApps(
backupDir = dir,
userId = selectedUserId.toString(),
filterPkgs = selectedPackages,
onProgress = { progress ->
val label = appInfos.find { it.packageName == progress.packageName }?.label
@@ -338,22 +370,18 @@ class RestoreFragment : Fragment() {
}
}
private fun formatSize(bytes: Long): String {
if (bytes <= 0) return "0 B"
val units = arrayOf("B", "KB", "MB", "GB")
val digitGroups = (Math.log10(bytes.toDouble()) / Math.log10(1024.0)).toInt()
return String.format(Locale.US, "%.1f %s", bytes / Math.pow(1024.0, digitGroups.toDouble()), units[digitGroups])
}
private fun setRunning(running: Boolean) {
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
}
private fun formatSize(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = arrayOf("KB", "MB", "GB", "TB")
val exp = (63 - bytes.countLeadingZeroBits()) / 10
val value = bytes.toDouble() / (1L shl (exp * 10))
return "%.1f %s".format(Locale.US, value, units[exp - 1].coerceAtMost(units.last()))
}
override fun onDestroyView() {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
ResticWrapper.cleanup()
}
super.onDestroyView()
_binding = null
}

Binary file not shown.

Binary file not shown.

View File

@@ -29,6 +29,91 @@
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="用户: "
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<Spinner
android:id="@+id/userSelector"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/sortAZButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:text="A-Z"
android:textSize="12sp"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/sortSizeButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="大小"
android:textSize="12sp"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/selectAllButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="全选"
android:textSize="12sp"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/deselectAllButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="取消全选"
android:textSize="12sp"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/showSystemSwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="显示系统应用"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:checked="false" />
</LinearLayout>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="match_parent"

View File

@@ -38,6 +38,27 @@
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="用户: "
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<Spinner
android:id="@+id/userSelector"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
<TextView
android:id="@+id/backupDirText"
android:layout_width="match_parent"