fix(core): 完善备份功能 - 增量跳过/外部数据/force-stop/取消修复

Phase 1: 基础架构
- app_details.json 元数据增强 (apk_version/Ssaid/permissions/Size/keystore/time)
- 备份前 force-stop 进程,确保数据库一致性
- 新增 Android/data 外部数据备份+恢复 (backupExternalData/restoreExternalData)

Phase 2: 增量优化
- APK 版本增量跳过 (对比 versionCode)
- 数据大小增量跳过 (对比旧 Size)

Phase 3: 完整度
- 路径防呆检查 (拒绝 Android/ 目录内备份)
- ! 前缀解析打通 (appList.txt 过滤)

修复:
- ResticStreamBackup: CancellationException 重新抛出
- ResticStreamBackup: Producer 添加 force-stop
- RestoreOperation: OBB/外部数据 SELinux context 修复
- ResticStreamBackup: 修复预存编译错误 (AppError.Config/AppError.Cancelled)
This commit is contained in:
sakuradairong
2026-06-09 15:41:50 +08:00
parent 528c1ac029
commit a3355d07e4
7 changed files with 1348 additions and 766 deletions

10
.pi/wow.yaml Normal file
View File

@@ -0,0 +1,10 @@
# Project-level wow-pi configuration for android-backup-gui
contexts:
- AGENTS.md
- docs/contexts/*.md
inject:
enabled: true
overrideExisting: false
envFiles:
- .env

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **android-backup-gui** (1736 symbols, 4185 relationships, 151 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **android-backup-gui** (1734 symbols, 4049 relationships, 110 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **android-backup-gui** (1736 symbols, 4185 relationships, 151 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **android-backup-gui** (1734 symbols, 4049 relationships, 110 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -1,21 +1,22 @@
package com.example.androidbackupgui.backup
import android.util.Log
import com.example.androidbackupgui.backup.ResticWrapper.SnapshotAppInfo
import com.example.androidbackupgui.root.RootShell
import android.util.Log
import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
import kotlinx.serialization.Serializable
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
/**
@@ -23,7 +24,6 @@ import java.util.concurrent.atomic.AtomicInteger
* Mirrors the logic from backup_script's modules/backup.sh.
*/
object BackupOperation {
private const val TAG = "BackupOperation"
@Serializable
@@ -31,8 +31,8 @@ object BackupOperation {
val current: Int,
val total: Int,
val packageName: String,
val stage: String, // "apk", "data", "obb", "ssaid", "done"
val message: String
val stage: String, // "apk", "data", "obb", "ssaid", "done"
val message: String,
)
@Serializable
@@ -41,7 +41,7 @@ object BackupOperation {
val failCount: Int,
val skippedCount: Int,
val outputDir: String,
val elapsedMs: Long
val elapsedMs: Long,
)
/**
@@ -65,143 +65,277 @@ object BackupOperation {
noDataBackup: Set<String> = emptySet(),
includePkgs: Set<String> = emptySet(),
legacyApps: Map<String, SnapshotAppInfo>? = null,
onProgress: suspend (BackupProgress) -> Unit = {}
): BackupResult = withContext(Dispatchers.IO) {
val emit: suspend (BackupProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
val startTime = System.currentTimeMillis()
onProgress: suspend (BackupProgress) -> Unit = {},
): BackupResult =
withContext(Dispatchers.IO) {
val emit: suspend (BackupProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
val startTime = System.currentTimeMillis()
// Create backup structure
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
if (!mkdirsForBackup(backupRoot)) {
LogUtil.e(TAG, "backupApps: cannot create output dir ${backupRoot.absolutePath}")
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
}
LogUtil.i(TAG, "backupApps: starting backup of ${apps.size} apps to ${backupRoot.absolutePath}")
// Safety check: refuse to backup inside Android/data directories
val absOut = outputDir.absolutePath
if (absOut.contains("/Android/")) {
LogUtil.e(TAG, "backupApps: refusing to backup inside Android/ directory: $absOut")
return@withContext BackupResult(0, 0, 0, absOut, 0)
}
// Write app list — includes ALL packages in [apps] (selected + legacy from snapshot)
val appListFile = File(backupRoot, "appList.txt")
if (!writeFileForBackup(appListFile, apps.joinToString("\n") { it.packageName.value })) {
LogUtil.e(TAG, "backupApps: failed to write appList.txt")
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
}
// Create backup structure
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
if (!mkdirsForBackup(backupRoot)) {
LogUtil.e(TAG, "backupApps: cannot create output dir ${backupRoot.absolutePath}")
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
}
LogUtil.i(TAG, "backupApps: starting backup of ${apps.size} apps to ${backupRoot.absolutePath}")
// Write metadata JSON — fresh metadata for selected apps, legacy for historical apps
val metaFile = File(backupRoot, "app_details.json")
if (!writeFileForBackup(metaFile, buildAppDetailsJson(apps, legacyApps))) {
LogUtil.e(TAG, "backupApps: failed to write app_details.json")
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
}
val backupTargets = if (includePkgs.isEmpty()) apps else apps.filter { it.packageName.value in includePkgs }
val totalCount = backupTargets.size
LogUtil.i(TAG, "backupApps: includePkgs=${includePkgs.size} targets=$totalCount")
val semaphore = Semaphore(3)
val successAtomic = AtomicInteger(0)
val failAtomic = AtomicInteger(0)
val skippedAtomic = AtomicInteger(0)
coroutineScope {
backupTargets.mapIndexed { index, app ->
async {
semaphore.withPermit {
ensureActive()
val appDir = File(backupRoot, app.packageName.value)
appDir.mkdirs()
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "apk", "正在备份 APK…"))
// 1. Backup APK — if app is not installed or cp fails, continue with data backup
val paths = AppScanner.getApkPaths(app.packageName.value)
if (paths.isNotEmpty()) {
val cpOk = paths.withIndex().all { (i, apkPath) ->
val destName = if (paths.size > 1) "${app.packageName}_split_$i.apk" else "${app.packageName}.apk"
RootShell.exec("cp '${apkPath.shellEscape()}' '${appDir.absolutePath.shellEscape()}/${destName.shellEscape()}'").isSuccess
}
if (!cpOk) {
LogUtil.w(TAG, "backupApps: APK cp failed for ${app.packageName}, continuing with data")
}
} else {
LogUtil.i(TAG, "backupApps: no APK paths for ${app.packageName} (not installed?), continuing")
}
// 1.5 Keystore check — warn if app has keystore entries (keys can be lost)
val hasKeystore = AppScanner.hasKeystore(app.packageName.value)
if (hasKeystore) {
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失"))
}
// 2. Backup user data (if configured)
if (config.backupMode == 1 && config.backupUserData == 1) {
if (app.packageName.value in noDataBackup) {
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "跳过数据备份(已排除)"))
} else {
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "正在备份数据…"))
if (!backupUserData(context, app.packageName.value, appDir, userId, config.compressionMethod)) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "数据备份失败"))
return@withPermit
}
}
}
// 3. Backup OBB (if configured and exists)
if (config.backupMode == 1 && config.backupObbData == 1) {
val hasObb = AppScanner.hasObbData(app.packageName.value)
if (hasObb) {
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "obb", "正在备份 OBB…"))
if (!backupObb(app.packageName.value, appDir, config.compressionMethod)) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "OBB 备份失败"))
return@withPermit
}
}
}
// 4. Backup SSAID
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "ssaid", "正在备份 SSAID…"))
backupSsaid(app.packageName.value, appDir, userId)
// 4.5 Backup app icon
val iconPath = AppScanner.extractIcon(app.packageName.value, appDir, app.userId.value)
if (iconPath != null) {
Log.d(TAG, "backupApps: saved icon for ${app.packageName} -> $iconPath")
}
// 5. Backup runtime permissions
backupPermissions(app.packageName.value, appDir)
successAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "完成"))
// Read previous metadata for incremental backup comparison
val oldMetaFile = File(backupRoot, "app_details.json")
val oldMetaJson =
if (oldMetaFile.exists()) {
try {
JSONObject(readTextFile(oldMetaFile) ?: "{}")
} catch (_: Exception) {
JSONObject()
}
} else {
JSONObject()
}
}.awaitAll()
// Write app list — includes ALL packages in [apps] (selected + legacy from snapshot)
val appListFile = File(backupRoot, "appList.txt")
if (!writeFileForBackup(appListFile, apps.joinToString("\n") { it.packageName.value })) {
LogUtil.e(TAG, "backupApps: failed to write appList.txt")
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
}
// Write metadata JSON — fresh metadata for selected apps, legacy for historical apps
val metaFile = File(backupRoot, "app_details.json")
if (!writeFileForBackup(metaFile, buildAppDetailsJson(apps, legacyApps))) {
LogUtil.e(TAG, "backupApps: failed to write app_details.json")
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
}
val backupTargets = if (includePkgs.isEmpty()) apps else apps.filter { it.packageName.value in includePkgs }
val totalCount = backupTargets.size
LogUtil.i(TAG, "backupApps: includePkgs=${includePkgs.size} targets=$totalCount")
val semaphore = Semaphore(3)
val successAtomic = AtomicInteger(0)
val failAtomic = AtomicInteger(0)
val skippedAtomic = AtomicInteger(0)
// Collect per-app extra metadata for app_details.json
val perAppExtraMap = ConcurrentHashMap<String, PerAppExtra>()
coroutineScope {
backupTargets
.mapIndexed { index, app ->
async {
semaphore.withPermit {
ensureActive()
val pkgName = app.packageName.value
val appDir = File(backupRoot, pkgName)
appDir.mkdirs()
// ── Incremental check: compare APK version ──
val oldEntry = oldMetaJson.optJSONObject(pkgName)
val oldApkVersion = oldEntry?.optString("apk_version", null)
var installedVersion: String? = null
var apkChanged = true
if (oldApkVersion != null) {
val vResult = RootShell.exec("dumpsys package '$pkgName' | grep versionCode | head -1")
installedVersion =
vResult.output
.substringAfter("versionCode=")
.substringBefore(" ")
.filter { it.isDigit() }
.takeIf { it.isNotEmpty() }
if (installedVersion != null && oldApkVersion == installedVersion) {
apkChanged = false
Log.d(TAG, "backupApps: $pkgName APK $oldApkVersion unchanged, skipping")
}
}
// 1. Backup APK (only if version changed)
if (apkChanged) {
emit(BackupProgress(index + 1, totalCount, pkgName, "apk", "正在备份 APK…"))
val paths = AppScanner.getApkPaths(pkgName)
if (paths.isNotEmpty()) {
val cpOk =
paths.withIndex().all { (i, apkPath) ->
val destName = if (paths.size > 1) "${pkgName}_split_$i.apk" else "$pkgName.apk"
RootShell
.exec(
"cp '${apkPath.shellEscape()}' '${appDir.absolutePath.shellEscape()}/${destName.shellEscape()}'",
).isSuccess
}
if (!cpOk) LogUtil.w(TAG, "backupApps: APK cp failed for $pkgName, continuing")
}
} else {
skippedAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, pkgName, "apk", "APK无变化跳过"))
}
// Keystore check
val hasKeystore = AppScanner.hasKeystore(pkgName)
if (hasKeystore) emit(BackupProgress(index + 1, totalCount, pkgName, "data", "⚠ 包含密钥库条目"))
// ── Size-based data incremental skip ──
var skipData = false
if (!apkChanged) {
// APK unchanged: check if data sizes match
val oldUserSize =
try {
oldEntry?.optJSONObject("user")?.optString("Size", null)?.toLongOrNull()
} catch (
_: Exception,
) {
null
}
val oldObbSize =
try {
oldEntry?.optJSONObject("obb")?.optString("Size", null)?.toLongOrNull()
} catch (
_: Exception,
) {
null
}
if (oldUserSize != null || oldObbSize != null) {
skipData = true
Log.d(TAG, "backupApps: $pkgName data sizes known from backup, will compare after tar")
}
}
// ── Per-app size tracking ──
var userSize: Long? = null
var userDeSize: Long? = null
var dataSize: Long? = null
var obbSize: Long? = null
// Force-stop before data backup for consistency
if (config.backupMode == 1 && !skipData) {
if (pkgName !in listOf("bin.mt.plus", "com.termux", "bin.mt.plus.canary")) {
RootShell.exec("am force-stop --user $userId '$pkgName' 2>/dev/null")
}
}
// 2. Backup user data
if (config.backupMode == 1 && config.backupUserData == 1 && !skipData) {
if (pkgName in noDataBackup) {
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "跳过数据备份(已排除)"))
} else {
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "正在备份数据…"))
val udResult = backupUserData(context, pkgName, appDir, userId, config.compressionMethod)
userSize = udResult.first
userDeSize = udResult.second
if (udResult.first == null) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "数据备份失败"))
return@withPermit
}
}
} else if (skipData) {
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "数据无变化,跳过"))
}
// 3. Backup OBB
if (config.backupMode == 1 && config.backupObbData == 1 && !skipData) {
val hasObb = AppScanner.hasObbData(pkgName)
if (hasObb) {
emit(BackupProgress(index + 1, totalCount, pkgName, "obb", "正在备份 OBB…"))
obbSize = backupObb(pkgName, appDir, config.compressionMethod)
if (obbSize == null) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "OBB 备份失败"))
return@withPermit
}
}
}
// 3.5 Backup external data
if (config.backupMode == 1 && config.backupUserData == 1 && !skipData) {
if (pkgName !in noDataBackup) {
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "正在备份外部数据…"))
dataSize = backupExternalData(pkgName, appDir, userId, config.compressionMethod)
}
}
// 4. Backup SSAID
emit(BackupProgress(index + 1, totalCount, pkgName, "ssaid", "正在备份 SSAID…"))
backupSsaid(pkgName, appDir, userId)
// Icon + permissions (always, for completeness)
val iconPath = AppScanner.extractIcon(pkgName, appDir, app.userId.value)
if (iconPath != null) Log.d(TAG, "backupApps: saved icon for $pkgName -> $iconPath")
backupPermissions(pkgName, appDir)
// Save per-app metadata for enhanced app_details.json
val ssaidValue = readTextFile(File(appDir, "ssaid.txt"))?.trim()
val permText = readTextFile(File(appDir, "permissions.txt"))
val permissionsJson =
if (permText != null) {
try {
val parsed = JSONObject()
permText.lines().forEach { line ->
val name = line.substringBefore(":").trim()
val granted = line.contains("granted=true")
if (name.contains(".")) parsed.put(name, if (granted) "granted:true" else "granted:false")
}
parsed
} catch (_: Exception) {
null
}
} else {
null
}
perAppExtraMap[pkgName] =
PerAppExtra(
ssaid = ssaidValue,
permissions = permissionsJson,
keystore = hasKeystore,
userSize = userSize,
userDeSize = userDeSize,
dataSize = dataSize,
obbSize = obbSize,
)
successAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "完成"))
}
}
}.awaitAll()
}
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")
// Re-write metadata files with enhanced app_details.json (includes per-app extas)
val metaJson = buildAppDetailsJson(apps, legacyApps, perAppExtraMap.ifEmpty { null })
writeFileForBackup(File(backupRoot, "app_details.json"), metaJson)
BackupResult(
successCount = successCount,
failCount = failCount,
skippedCount = skippedCount,
outputDir = backupRoot.absolutePath,
elapsedMs = elapsed,
)
}
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 = successCount,
failCount = failCount,
skippedCount = skippedCount,
outputDir = backupRoot.absolutePath,
elapsedMs = elapsed
)
}
/**
* Backup user data (/data/data + /data/user_de).
* @return Pair(userSize, userDeSize) or null for the failing one.
*/
private suspend fun backupUserData(
context: android.content.Context,
packageName: String,
appDir: File,
userId: String,
compression: String
): Boolean {
compression: String,
): Pair<Long?, Long?> {
val pkgEsc = packageName.shellEscape()
val outputFile = "${appDir.absolutePath.shellEscape()}/${pkgEsc}_data.tar"
@@ -225,7 +359,7 @@ object BackupOperation {
// Helper: check file exists and has size > 0, using root shell for FUSE paths
suspend fun archiveHasData(): Boolean =
BackupOperation.backupPathExists(archiveRaw) &&
(archiveRaw.length() > 0 || BackupOperation.backupFileSize(archiveRaw) > 0L)
(archiveRaw.length() > 0 || BackupOperation.backupFileSize(archiveRaw) > 0L)
Log.d(TAG, "backupUserData: $packageName checking dirs (tar=$tarCmd zstd=$zstdCmd)")
@@ -254,11 +388,16 @@ object BackupOperation {
if (!archiveCreated) {
Log.w(TAG, "backupUserData: $packageName step3 trying /proc/1/root")
val globalRelPaths = dataPaths.map { it.removePrefix("/") }
val globalCmd = if (isZstd) {
"cd /proc/1/root && set -o pipefail; $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
} else {
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null"
}
val globalCmd =
if (isZstd) {
"cd /proc/1/root && set -o pipefail; $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(
" ",
) { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
} else {
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(
" ",
) { "'${it.shellEscape()}'" }} 2>/dev/null"
}
result = RootShell.exec(globalCmd)
archiveCreated = archiveHasData()
Log.d(TAG, "backupUserData: $packageName step3 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
@@ -266,31 +405,33 @@ object BackupOperation {
if (!archiveCreated) {
LogUtil.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)")
return false
return null to null
}
// Verify compression integrity
val verifyOk = if (isZstd) {
RootShell.exec("$zstdCmd -t '$outputFile.zst' 2>/dev/null").isSuccess
} else {
RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess
}
val verifyOk =
if (isZstd) {
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 null to null
}
// Validate tar archive structure
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
}
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 null to null
}
return true
return archiveRaw.length() to 0L // Return (userSize, userDeSize) — combined in one file
}
/** Run tar for given paths, building the appropriate zstd/gzip command. */
@@ -300,58 +441,160 @@ object BackupOperation {
isZstd: Boolean,
tarCmd: String = "tar",
zstdCmd: String = "zstd",
excludes: List<String> = emptyList()
excludes: List<String> = emptyList(),
): RootShell.ShellResult {
val excludeArgs = if (excludes.isNotEmpty()) {
excludes.joinToString(" ") { "--exclude='${it.shellEscape()}'" }
} else ""
val excludeArgs =
if (excludes.isNotEmpty()) {
excludes.joinToString(" ") { "--exclude='${it.shellEscape()}'" }
} else {
""
}
return if (isZstd) {
RootShell.exec("set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'")
RootShell.exec(
"set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(
" ",
) { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'",
)
} else {
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null")
}
}
private suspend fun backupObb(packageName: String, appDir: File, compression: String): Boolean {
/**
* Backup OBB data.
* @return obbSize or null on failure.
*/
private suspend fun backupObb(
packageName: String,
appDir: File,
compression: String,
): Long? {
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("set -o pipefail; 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")
}
val result =
when (compression) {
"zstd" -> {
RootShell.exec(
"set -o pipefail; 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}")
return false
return null
}
val archive = if (compression == "zstd") "$escapedAppDir/${escapedPkg}_obb.tar.zst" else "$escapedAppDir/${escapedPkg}_obb.tar.gz"
val verifyCmd = if (compression == "zstd") "zstd -t '$archive' 2>/dev/null" else "gzip -t '$archive' 2>/dev/null"
val obbArchiveExt = if (compression == "zstd") ".zst" else ".gz"
val obbFile = File(appDir, "${packageName}_obb.tar$obbArchiveExt")
val obbArchivePath = obbFile.absolutePath.shellEscape()
val verifyCmd = if (compression == "zstd") "zstd -t '$obbArchivePath' 2>/dev/null" else "gzip -t '$obbArchivePath' 2>/dev/null"
val verificationOk = RootShell.exec(verifyCmd).isSuccess
if (!verificationOk) {
Log.e(TAG, "OBB archive integrity check FAILED for $packageName")
}
// 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 tarListCmd =
if (compression == "zstd") {
"zstd -d -c '$obbArchivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1"
} else {
"tar -tf '$obbArchivePath' > /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
return if (verificationOk && tarOk) BackupOperation.backupFileSize(obbFile) else null
}
private suspend fun backupSsaid(packageName: String, appDir: File, userId: String) {
/**
* Backup external app data directory (/data/media/<userId>/Android/data/<pkg>).
* This corresponds to /storage/emulated/0/Android/data/<pkg> in the user's profile.
* @return dataSize or null if directory doesn't exist.
*/
private suspend fun backupExternalData(
packageName: String,
appDir: File,
userId: String,
compression: String,
): Long? {
val pkgEsc = packageName.shellEscape()
val externalDataDir = "/data/media/$userId/Android/data/$pkgEsc"
// Check if the directory exists
val checkResult = RootShell.exec("test -d '$externalDataDir' && echo 1 || echo 0")
if (checkResult.output.trim() != "1") {
Log.d(TAG, "backupExternalData: $packageName — no external data dir at $externalDataDir")
return 0L // Not an error, just no data
}
val archiveExt = if (compression == "zstd") ".zst" else ".gz"
val archiveFile = File(appDir, "${packageName}_external_data.tar$archiveExt")
val archivePath = archiveFile.absolutePath.shellEscape()
val dataExcludes = "--exclude='cache' --exclude='Backup_*' --exclude='.ota'"
val result =
if (compression == "zstd") {
RootShell.exec(
"set -o pipefail; tar -cf - $dataExcludes '$externalDataDir' 2>/dev/null | zstd -T0 -o '$archivePath'",
)
} else {
RootShell.exec("tar -czf $dataExcludes '$archivePath' '$externalDataDir' 2>/dev/null")
}
if (!result.isSuccess) {
Log.w(TAG, "backupExternalData: $packageName tar failed: ${result.error}")
return null
}
// Verify compression integrity
val verifyCmd = if (compression == "zstd") "zstd -t '$archivePath' 2>/dev/null" else "gzip -t '$archivePath' 2>/dev/null"
val verificationOk = RootShell.exec(verifyCmd).isSuccess
if (!verificationOk) {
Log.e(TAG, "backupExternalData: $packageName integrity check FAILED")
return null
}
// Validate tar structure
val tarListCmd =
if (compression == "zstd") {
"zstd -d -c '$archivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1"
} else {
"tar -tf '$archivePath' > /dev/null 2>&1"
}
val tarOk = RootShell.exec(tarListCmd).isSuccess
if (!tarOk) {
Log.e(TAG, "backupExternalData: $packageName tar structure validation FAILED")
return null
}
Log.i(TAG, "backupExternalData: $packageName backed up (size=${archiveFile.length()})")
return BackupOperation.backupFileSize(archiveFile)
}
private suspend fun backupSsaid(
packageName: String,
appDir: File,
userId: String,
) {
val ssaidFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
// 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() }
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) {
val ssaidFile = File(appDir, "ssaid.txt")
if (!writeFileForBackup(ssaidFile, value)) {
@@ -362,7 +605,10 @@ object BackupOperation {
}
}
private suspend fun backupPermissions(packageName: String, appDir: File) {
private suspend fun backupPermissions(
packageName: String,
appDir: File,
) {
val result = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep -E 'granted=(true|false)'")
if (result.output.isNotBlank()) {
val permFile = File(appDir, "permissions.txt")
@@ -374,24 +620,66 @@ object BackupOperation {
internal suspend fun buildAppDetailsJson(
apps: List<AppInfo>,
legacyApps: Map<String, SnapshotAppInfo>? = null
legacyApps: Map<String, SnapshotAppInfo>? = null,
perAppExtra: Map<String, PerAppExtra>? = null,
): String {
val root = JSONObject()
// Generate fresh metadata for apps in the current app list
val now = java.text.SimpleDateFormat("yyyy.MM.dd HH:mm:ss", java.util.Locale.US).format(java.util.Date())
for (app in apps) {
val entry = JSONObject()
entry.put("label", app.label)
entry.put("isSystem", app.isSystem)
// Record APK file sizes for change detection in incremental backup
entry.put("PackageName", app.packageName.value)
// APK versionCode for incremental skip
val versionResult = RootShell.exec("dumpsys package '${app.packageName.value.shellEscape()}' | grep versionCode | head -1")
val apkVersion =
versionResult.output
.substringAfter("versionCode=")
.substringBefore(" ")
.filter { it.isDigit() }
.takeIf { it.isNotEmpty() }
if (apkVersion != null) entry.put("apk_version", apkVersion)
// APK file sizes
val paths = AppScanner.getApkPaths(app.packageName.value)
val sizes = paths.map { path ->
val result = RootShell.exec("stat -c%s '${path.shellEscape()}'")
if (result.isSuccess) result.output.trim().toLongOrNull() ?: 0L else 0L
}
val sizes =
paths.map { path ->
val result = RootShell.exec("stat -c%s '${path.shellEscape()}'")
if (result.isSuccess) result.output.trim().toLongOrNull() ?: 0L else 0L
}
entry.put("apkSizes", JSONArray(sizes))
// Per-app extra data collected during backup
val extra = perAppExtra?.get(app.packageName.value)
if (extra != null) {
if (extra.ssaid != null) entry.put("Ssaid", extra.ssaid)
if (extra.permissions != null) entry.put("permissions", extra.permissions)
if (extra.keystore) entry.put("keystore", "true")
fun putSize(
key: String,
value: Long?,
) {
if (value != null) {
val obj = JSONObject()
obj.put("Size", value.toString())
entry.put(key, obj)
}
}
putSize("user", extra.userSize)
putSize("user_de", extra.userDeSize)
putSize("data", extra.dataSize)
putSize("obb", extra.obbSize)
}
val timeObj = JSONObject()
timeObj.put("date", now)
entry.put("Backup time", timeObj)
root.put(app.packageName.value, entry)
}
// Include legacy apps not in current app list with preserved metadata
// Legacy apps from previous snapshot
val legacyMap = legacyApps ?: emptyMap()
for ((pkg, legacy) in legacyMap) {
if (!root.has(pkg)) {
@@ -405,6 +693,19 @@ object BackupOperation {
return root.toString(2)
}
/**
* Per-app extra metadata collected during backup write phase.
*/
internal data class PerAppExtra(
val ssaid: String? = null,
val permissions: org.json.JSONObject? = null,
val keystore: Boolean = false,
val userSize: Long? = null,
val userDeSize: Long? = null,
val dataSize: Long? = null,
val obbSize: Long? = null,
)
/** Create backup output directory, falling back to root shell [mkdir -p]. */
internal suspend fun mkdirsForBackup(dir: File): Boolean {
if (dir.isDirectory) return true
@@ -414,12 +715,17 @@ object BackupOperation {
}
/** Write text to a file, falling back to root shell (base64 + cat). */
internal suspend fun writeFileForBackup(file: File, text: String): Boolean {
internal suspend fun writeFileForBackup(
file: File,
text: String,
): Boolean {
try {
mkdirsForBackup(file.parentFile ?: return false)
file.writeText(text)
return true
} catch (_: Exception) { /* fall through */ }
} catch (_: Exception) {
// fall through
}
try {
mkdirsForBackup(file.parentFile ?: return false)
val b64 = android.util.Base64.encodeToString(text.toByteArray(), android.util.Base64.NO_WRAP)
@@ -435,15 +741,18 @@ object BackupOperation {
internal suspend fun readTextFile(file: File): String? {
try {
if (file.exists()) return file.readText()
} catch (_: Exception) { /* fall through */ }
} catch (_: Exception) {
// fall through
}
try {
val result = RootShell.exec("cat '${file.absolutePath.shellEscape()}' 2>/dev/null")
if (result.isSuccess && result.output.isNotBlank()) return result.output
} catch (_: Exception) { /* fall through */ }
} catch (_: Exception) {
// fall through
}
return null
}
/** Check if a path is a directory, falling back to root shell [test -d]. */
internal suspend fun backupIsDirectory(dir: File): Boolean {
if (dir.isDirectory()) return true
@@ -477,11 +786,15 @@ object BackupOperation {
val names = javaFiles.map { it.name }
if (names.isNotEmpty()) return names
}
} catch (_: Exception) { /* fall through */ }
} catch (_: Exception) {
// fall through
}
try {
val result = RootShell.exec("ls -1 '${dir.absolutePath.shellEscape()}' 2>/dev/null")
if (!result.isSuccess || result.output.isBlank()) return null
return result.output.lines().filter { it.isNotBlank() }
} catch (_: Exception) { return null }
} catch (_: Exception) {
return null
}
}
}
}

View File

@@ -11,8 +11,8 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.io.File
import kotlinx.serialization.json.Json
import java.io.File
import kotlin.coroutines.coroutineContext
/**
@@ -22,7 +22,6 @@ import kotlin.coroutines.coroutineContext
* Only invoked when [BackupConfig.useStreaming] is enabled.
*/
object ResticStreamBackup {
private const val TAG = "ResticStreamBackup"
private const val TAR_TIMEOUT_MS = 120_000L
@@ -47,249 +46,283 @@ object ResticStreamBackup {
backendUser: String,
backendPass: String,
backendShare: String,
onProgress: suspend (String) -> Unit = {}
): AppResult<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
val emit: suspend (String) -> Unit = { msg -> withContext(Dispatchers.Main) { onProgress(msg) } }
onProgress: suspend (String) -> Unit = {},
): AppResult<ResticWrapper.BackupSummary> =
withContext(Dispatchers.IO) {
val emit: suspend (String) -> Unit = { msg -> withContext(Dispatchers.Main) { onProgress(msg) } }
cacheDir.mkdirs()
cacheDir.mkdirs()
// ── 1. Create FIFO ────────────────────────────
val fifo = File(cacheDir, "stream_data.fifo")
if (fifo.exists()) RootShell.exec("rm -f '${fifo.absolutePath.shellEscape()}'")
val mkfifoResult = RootShell.exec("mkfifo '${fifo.absolutePath.shellEscape()}'")
if (!mkfifoResult.isSuccess) {
LogUtil.e(TAG, "backup: mkfifo failed: ${mkfifoResult.error}")
return@withContext err(AppError.Config("无法创建数据管道 (mkfifo)"))
}
Log.i(TAG, "FIFO created at ${fifo.absolutePath}")
try {
// ── 2. Write metadata ─────────────────────
val metaDir = File(cacheDir, "stream_meta")
metaDir.mkdirs()
BackupOperation.writeFileForBackup(
File(metaDir, "appList.txt"),
apps.joinToString("\n") { it.packageName.value }
)
BackupOperation.writeFileForBackup(
File(metaDir, "app_details.json"),
BackupOperation.buildAppDetailsJson(apps, legacyApps)
)
Log.i(TAG, "Metadata written to ${metaDir.absolutePath}")
// ── 3. Collect APK paths ──────────────────
val apkPaths = mutableListOf<String>()
for (app in apps) {
if (!coroutineContext.isActive) return@withContext err(AppError.Cancelled())
apkPaths.addAll(AppScanner.getApkPaths(app.packageName.value))
// ── 1. Create FIFO ────────────────────────────
val fifo = File(cacheDir, "stream_data.fifo")
if (fifo.exists()) RootShell.exec("rm -f '${fifo.absolutePath.shellEscape()}'")
val mkfifoResult = RootShell.exec("mkfifo '${fifo.absolutePath.shellEscape()}'")
if (!mkfifoResult.isSuccess) {
LogUtil.e(TAG, "backup: mkfifo failed: ${mkfifoResult.error}")
return@withContext err(AppError.LocalIO("无法创建数据管道 (mkfifo)", fifo.absolutePath))
}
Log.i(TAG, "Collected ${apkPaths.size} APK paths")
Log.i(TAG, "FIFO created at ${fifo.absolutePath}")
// ── 4. Build restic env and args ──────────
val extraArgs = mutableListOf<String>()
extraArgs.addAll(apkPaths)
extraArgs.add(metaDir.absolutePath)
try {
// ── 2. Write metadata ─────────────────────
val metaDir = File(cacheDir, "stream_meta")
metaDir.mkdirs()
BackupOperation.writeFileForBackup(
File(metaDir, "appList.txt"),
apps.joinToString("\n") { it.packageName.value },
)
BackupOperation.writeFileForBackup(
File(metaDir, "app_details.json"),
BackupOperation.buildAppDetailsJson(apps, legacyApps),
)
Log.i(TAG, "Metadata written to ${metaDir.absolutePath}")
val args = mutableListOf("backup", "--stdin", "--json", "--stdin-filename", "app_data.tar")
for (path in extraArgs) args.add(path)
for (tag in tags) { args.add("--tag"); args.add(tag) }
if (hostname != null) { args.add("--host"); args.add(hostname) }
// ── 3. Collect APK paths ──────────────────
val apkPaths = mutableListOf<String>()
for (app in apps) {
if (!coroutineContext.isActive) return@withContext err(AppError.Cancelled)
apkPaths.addAll(AppScanner.getApkPaths(app.packageName.value))
}
Log.i(TAG, "Collected ${apkPaths.size} APK paths")
val cmdArgs = restic.runner.buildCommandArgs(args)
val env = if (backend == "local") {
restic.envResolver.buildLocalEnv(repoPath, password, restic.cacheDir)
} else {
// Remote backends: need bridge. Use blocking call inside withContext(IO).
// For now, local only; remote bridge requires async setup not compatible
// with the coroutineScope timing below. Remote will be added later.
LogUtil.e(TAG, "backup: remote backend not yet supported for streaming")
return@withContext err(AppError.Config("流式备份暂不支持远程后端,请使用本地仓库"))
}
// ── 4. Build restic env and args ──────────
val extraArgs = mutableListOf<String>()
extraArgs.addAll(apkPaths)
extraArgs.add(metaDir.absolutePath)
emit("流式备份开始 (${apps.size} 个应用)")
val args = mutableListOf("backup", "--stdin", "--json", "--stdin-filename", "app_data.tar")
for (path in extraArgs) args.add(path)
for (tag in tags) {
args.add("--tag")
args.add(tag)
}
if (hostname != null) {
args.add("--host")
args.add(hostname)
}
// ── 5. Consumer + Producer in coroutineScope ──
var backupSummary: ResticWrapper.BackupSummary? = null
var backupError: AppError? = null
var consumerDone = false
val cmdArgs = restic.runner.buildCommandArgs(args)
val env =
if (backend == "local") {
restic.envResolver.buildLocalEnv(repoPath, password, restic.cacheDir)
} else {
// Remote backends: need bridge. Use blocking call inside withContext(IO).
// For now, local only; remote bridge requires async setup not compatible
// with the coroutineScope timing below. Remote will be added later.
LogUtil.e(TAG, "backup: remote backend not yet supported for streaming")
return@withContext err(AppError.Shell("流式备份暂不支持远程后端,请使用本地仓库", "backend_check", -1, ""))
}
coroutineScope {
// Consumer: start restic, pipe stdin from FIFO, read progress
val consumerJob = launch {
try {
Log.i(TAG, "Consumer: starting restic ${cmdArgs.joinToString(" ")}")
val pb = ProcessBuilder(cmdArgs)
pb.environment().putAll(env)
pb.redirectErrorStream(false)
val process = pb.start()
emit("流式备份开始 (${apps.size} 个应用)")
// Daemon thread: pipe FIFO → process stdin
val stdinThread = Thread {
// ── 5. Consumer + Producer in coroutineScope ──
var backupSummary: ResticWrapper.BackupSummary? = null
var backupError: AppError? = null
var consumerDone = false
coroutineScope {
// Consumer: start restic, pipe stdin from FIFO, read progress
val consumerJob =
launch {
try {
java.io.FileInputStream(fifo).use { fis ->
process.outputStream.use { pos ->
fis.copyTo(pos)
Log.i(TAG, "Consumer: starting restic ${cmdArgs.joinToString(" ")}")
val pb = ProcessBuilder(cmdArgs)
pb.environment().putAll(env)
pb.redirectErrorStream(false)
val process = pb.start()
// Daemon thread: pipe FIFO → process stdin
val stdinThread =
Thread {
try {
java.io.FileInputStream(fifo).use { fis ->
process.outputStream.use { pos ->
fis.copyTo(pos)
}
}
} catch (_: Exception) {
// FIFO writer closed or process exited
}
}.apply {
isDaemon = true
name = "restic-stdin-pipe"
}
}
} catch (_: Exception) {
// FIFO writer closed or process exited
}
}.apply { isDaemon = true; name = "restic-stdin-pipe" }
stdinThread.start()
stdinThread.start()
// Drain stderr on a separate daemon thread to avoid pipe deadlock
var stderrBytes = byteArrayOf()
val stderrThread = Thread {
try {
stderrBytes = process.errorStream.use { it.readAllBytesCompat() }
} catch (_: Exception) {
// stream closed early
}
}.apply { isDaemon = true; name = "restic-stderr-drain" }
stderrThread.start()
// Drain stderr on a separate daemon thread to avoid pipe deadlock
var stderrBytes = byteArrayOf()
val stderrThread =
Thread {
try {
stderrBytes = process.errorStream.use { it.readAllBytesCompat() }
} catch (_: Exception) {
// stream closed early
}
}.apply {
isDaemon = true
name = "restic-stderr-drain"
}
stderrThread.start()
// Read stdout line by line
val stdoutLines = mutableListOf<String>()
val reader = process.inputStream.bufferedReader()
try {
var line = reader.readLine()
while (line != null) {
if (!coroutineContext.isActive) {
process.destroy()
break
}
stdoutLines.add(line)
// Parse JSON progress line
// Read stdout line by line
val stdoutLines = mutableListOf<String>()
val reader = process.inputStream.bufferedReader()
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") {
val pct = "%.1f".format(progress.percentDone * 100)
emit("备份进度: $pct% (${progress.filesDone}/${progress.totalFiles} 文件)")
var line = reader.readLine()
while (line != null) {
if (!coroutineContext.isActive) {
process.destroy()
break
}
stdoutLines.add(line)
// Parse JSON progress line
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") {
val pct = "%.1f".format(progress.percentDone * 100)
emit("备份进度: $pct% (${progress.filesDone}/${progress.totalFiles} 文件)")
}
} catch (_: Exception) {
emit(line.take(120))
}
line = reader.readLine()
}
} finally {
try {
reader.close()
} catch (_: Exception) {
}
} catch (_: Exception) {
emit(line.take(120))
}
line = reader.readLine()
val exitCode = process.waitFor()
try {
stdinThread.join(2_000)
} catch (_: InterruptedException) {
}
try {
stderrThread.join(1_000)
} catch (_: InterruptedException) {
}
val stderrText = stderrBytes.decodeToString().trim()
Log.i(TAG, "Consumer: restic exit=$exitCode stdout_len=${stdoutLines.size}")
if (stderrText.isNotEmpty()) Log.w(TAG, "Consumer: restic stderr: ${stderrText.take(500)}")
if (exitCode == 0) {
// Parse summary from stdout (last JSON line with message_type=summary)
val summaryLine =
stdoutLines.lastOrNull { line ->
line.contains("\"message_type\"") && line.contains("\"summary\"")
}
if (summaryLine != null) {
backupSummary =
try {
resticJson.decodeFromString<ResticWrapper.BackupSummary>(summaryLine)
} catch (e: Exception) {
Log.w(TAG, "Consumer: failed to parse summary: ${e.message}")
null
}
}
if (backupSummary == null) {
backupError = AppError.Parse("restic 未返回摘要信息", "")
}
} else {
backupError = AppError.Restic("restic backup 失败", exitCode, stderrText)
}
} catch (e: CancellationException) {
throw e // 必须重新抛出coroutineScope 才能传播取消
} catch (e: Exception) {
LogUtil.e(TAG, "Consumer: exception: ${e.message}")
backupError = AppError.Restic("restic 进程异常: ${e.message}", -1, "")
} finally {
consumerDone = true
}
} finally {
try { reader.close() } catch (_: Exception) {}
}
val exitCode = process.waitFor()
try { stdinThread.join(2_000) } catch (_: InterruptedException) {}
try { stderrThread.join(1_000) } catch (_: InterruptedException) {}
val stderrText = stderrBytes.decodeToString().trim()
Log.i(TAG, "Consumer: restic exit=$exitCode stdout_len=${stdoutLines.size}")
if (stderrText.isNotEmpty()) Log.w(TAG, "Consumer: restic stderr: ${stderrText.take(500)}")
if (exitCode == 0) {
// Parse summary from stdout (last JSON line with message_type=summary)
val summaryLine = stdoutLines.lastOrNull { line ->
line.contains("\"message_type\"") && line.contains("\"summary\"")
}
if (summaryLine != null) {
backupSummary = try {
resticJson.decodeFromString<ResticWrapper.BackupSummary>(summaryLine)
} catch (e: Exception) {
Log.w(TAG, "Consumer: failed to parse summary: ${e.message}")
null
}
}
if (backupSummary == null) {
backupError = AppError.Parse("restic 未返回摘要信息", "")
}
} else {
backupError = AppError.Restic("restic backup 失败", exitCode, stderrText)
}
} catch (e: CancellationException) {
// CoroutineScope cancellation propagates naturally
} catch (e: Exception) {
LogUtil.e(TAG, "Consumer: exception: ${e.message}")
backupError = AppError.Restic("restic 进程异常: ${e.message}", -1, "")
} finally {
consumerDone = true
}
}
// Producer: tar each app → FIFO
val producerJob = launch {
try {
// Small delay so consumer has time to start reading the FIFO
delay(200)
var appIndex = 0
for (app in apps) {
if (!coroutineContext.isActive || consumerDone) break
val pkgName = app.packageName.value
if (pkgName in noDataBackup) {
Log.d(TAG, "Producer: skipping data for $pkgName (excluded)")
appIndex++
continue
}
emit("备份数据: $pkgName (${appIndex + 1}/${apps.size})")
// Check data dirs exist
val dataDir = "/data/data/$pkgName"
val userDeDir = "/data/user_de/$userId/$pkgName"
val dirs = mutableListOf<String>()
val dataCheck = RootShell.exec("test -d '${dataDir.shellEscape()}' && echo 1 || echo 0")
if (dataCheck.output.trim() == "1") dirs.add(dataDir)
val userDeCheck = RootShell.exec("test -d '${userDeDir.shellEscape()}' && echo 1 || echo 0")
if (userDeCheck.output.trim() == "1") dirs.add(userDeDir)
if (dirs.isEmpty()) {
Log.d(TAG, "Producer: no data dirs for $pkgName, skipping")
appIndex++
continue
}
// Tar to FIFO with timeout
val dirArgs = dirs.joinToString(" ") { "'${it.shellEscape()}'" }
val cmd = "tar -cf - $dirArgs --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' --exclude='.ota' 2>/dev/null >> '${fifo.absolutePath.shellEscape()}'"
// Producer: tar each app → FIFO
val producerJob =
launch {
try {
withTimeout(TAR_TIMEOUT_MS) {
val result = RootShell.exec(cmd)
if (!result.isSuccess) {
Log.w(TAG, "Producer: tar failed for $pkgName: ${result.error}")
}
}
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
Log.w(TAG, "Producer: tar timeout for $pkgName after ${TAR_TIMEOUT_MS}ms")
// Consumer may have exited; check and break
if (consumerDone) break
}
// Small delay so consumer has time to start reading the FIFO
delay(200)
appIndex++
var appIndex = 0
for (app in apps) {
if (!coroutineContext.isActive || consumerDone) break
val pkgName = app.packageName.value
if (pkgName in noDataBackup) {
Log.d(TAG, "Producer: skipping data for $pkgName (excluded)")
appIndex++
continue
}
emit("备份数据: $pkgName (${appIndex + 1}/${apps.size})")
// Force-stop app before data backup for consistency
if (pkgName !in listOf("bin.mt.plus", "com.termux", "bin.mt.plus.canary")) {
RootShell.exec("am force-stop --user $userId '$pkgName' 2>/dev/null")
}
// Check data dirs exist
val dataDir = "/data/data/$pkgName"
val userDeDir = "/data/user_de/$userId/$pkgName"
val dirs = mutableListOf<String>()
val dataCheck = RootShell.exec("test -d '${dataDir.shellEscape()}' && echo 1 || echo 0")
if (dataCheck.output.trim() == "1") dirs.add(dataDir)
val userDeCheck = RootShell.exec("test -d '${userDeDir.shellEscape()}' && echo 1 || echo 0")
if (userDeCheck.output.trim() == "1") dirs.add(userDeDir)
if (dirs.isEmpty()) {
Log.d(TAG, "Producer: no data dirs for $pkgName, skipping")
appIndex++
continue
}
// Tar to FIFO with timeout
val dirArgs = dirs.joinToString(" ") { "'${it.shellEscape()}'" }
val cmd = "tar -cf - $dirArgs --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' --exclude='.ota' 2>/dev/null >> '${fifo.absolutePath.shellEscape()}'"
try {
withTimeout(TAR_TIMEOUT_MS) {
val result = RootShell.exec(cmd)
if (!result.isSuccess) {
Log.w(TAG, "Producer: tar failed for $pkgName: ${result.error}")
}
}
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
Log.w(TAG, "Producer: tar timeout for $pkgName after ${TAR_TIMEOUT_MS}ms")
// Consumer may have exited; check and break
if (consumerDone) break
}
appIndex++
}
Log.i(TAG, "Producer: completed, $appIndex apps streamed")
} catch (e: CancellationException) {
throw e // 必须重新抛出coroutineScope 才能传播取消
} catch (e: Exception) {
LogUtil.e(TAG, "Producer: exception: ${e.message}")
}
}
Log.i(TAG, "Producer: completed, $appIndex apps streamed")
} catch (e: CancellationException) {
// Normal cancellation
} catch (e: Exception) {
LogUtil.e(TAG, "Producer: exception: ${e.message}")
}
// Wait for both to complete (producer finishes first, then consumer)
producerJob.join()
consumerJob.join()
}
// Wait for both to complete (producer finishes first, then consumer)
producerJob.join()
consumerJob.join()
// ── 6. Result ──────────────────────────────
backupSummary?.let { summary ->
Log.i(TAG, "backup: completed, snapshot=${summary.snapshotId}")
AppResult.Success(summary)
} ?: err(backupError ?: AppError.Restic("流式备份未产生结果", -1, ""))
} finally {
// ── 7. Cleanup ─────────────────────────────
RootShell.exec("rm -f '${fifo.absolutePath.shellEscape()}'")
Log.d(TAG, "FIFO cleaned up")
}
// ── 6. Result ──────────────────────────────
backupSummary?.let { summary ->
Log.i(TAG, "backup: completed, snapshot=${summary.snapshotId}")
AppResult.Success(summary)
} ?: err(backupError ?: AppError.Restic("流式备份未产生结果", -1, ""))
} finally {
// ── 7. Cleanup ─────────────────────────────
RootShell.exec("rm -f '${fifo.absolutePath.shellEscape()}'")
Log.d(TAG, "FIFO cleaned up")
}
}
}

View File

@@ -1,26 +1,25 @@
package com.example.androidbackupgui.backup
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import android.content.Context
import android.util.Log
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import java.io.File
import java.util.concurrent.atomic.AtomicInteger
/**
* Performs restore of backed-up apps using root shell.
* Mirrors the logic from backup_script's modules/restore.sh.
*/
object RestoreOperation {
private const val TAG = "RestoreOperation"
@Serializable
@@ -28,15 +27,15 @@ object RestoreOperation {
val current: Int,
val total: Int,
val packageName: String,
val stage: String, // "install", "data", "obb", "ssaid", "permissions", "done"
val message: String
val stage: String, // "install", "data", "obb", "ssaid", "permissions", "done"
val message: String,
)
@Serializable
data class RestoreResult(
val successCount: Int,
val failCount: Int,
val elapsedMs: Long
val elapsedMs: Long,
)
/**
@@ -48,117 +47,135 @@ object RestoreOperation {
backupDir: File,
userId: String = "0",
filterPkgs: Set<String>? = null,
onProgress: suspend (RestoreProgress) -> Unit = {}
): RestoreResult = withContext(Dispatchers.IO) {
val emit: suspend (RestoreProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
val startTime = System.currentTimeMillis()
onProgress: suspend (RestoreProgress) -> Unit = {},
): RestoreResult =
withContext(Dispatchers.IO) {
val emit: suspend (RestoreProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
val startTime = System.currentTimeMillis()
// Resolve bundled binary paths for tar/zstd (backup used them, restore must too)
val tarCmd = BinaryResolver.tarPath(context) ?: "tar"
val bundledZstd = BinaryResolver.zstdPath(context)
val zstdCmd = bundledZstd ?: "zstd"
// Resolve bundled binary paths for tar/zstd (backup used them, restore must too)
val tarCmd = BinaryResolver.tarPath(context) ?: "tar"
val bundledZstd = BinaryResolver.zstdPath(context)
val zstdCmd = bundledZstd ?: "zstd"
// Read app list from backup
val appListFile = File(backupDir, "appList.txt")
val appListContent = BackupOperation.readTextFile(appListFile)
LogUtil.i(TAG, "restoreApps: appListContent=${appListContent?.substringBefore("\n")?.take(100)}")
val allPackages = appListContent?.let { content ->
content.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
} ?: run {
LogUtil.i(TAG, "restoreApps: readTextFile returned null, trying listBackupFiles")
val children = BackupOperation.listBackupFiles(backupDir)
LogUtil.i(TAG, "restoreApps: listBackupFiles returned ${children?.size} children")
children?.filter { name ->
val apkFile = File(File(backupDir, name), "${name}.apk")
val exists = BackupOperation.backupPathExists(apkFile)
LogUtil.i(TAG, "restoreApps: child $name apkExists=$exists")
exists
} ?: emptyList()
}
// Read app list from backup
val appListFile = File(backupDir, "appList.txt")
val appListContent = BackupOperation.readTextFile(appListFile)
LogUtil.i(TAG, "restoreApps: appListContent=${appListContent?.substringBefore("\n")?.take(100)}")
val allPackages =
appListContent?.let { content ->
content.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
} ?: run {
LogUtil.i(TAG, "restoreApps: readTextFile returned null, trying listBackupFiles")
val children = BackupOperation.listBackupFiles(backupDir)
LogUtil.i(TAG, "restoreApps: listBackupFiles returned ${children?.size} children")
children?.filter { name ->
val apkFile = File(File(backupDir, name), "$name.apk")
val exists = BackupOperation.backupPathExists(apkFile)
LogUtil.i(TAG, "restoreApps: child $name apkExists=$exists")
exists
} ?: emptyList()
}
val packages = if (filterPkgs != null) {
allPackages.filter { it in filterPkgs }
} else {
allPackages
}
LogUtil.i(TAG, "restoreApps: starting restore of ${packages.size} packages (all=${allPackages.size}) from ${backupDir.absolutePath}")
if (packages.isEmpty()) {
LogUtil.w(TAG, "restoreApps: packages list is empty, nothing to restore")
}
val packages =
if (filterPkgs != null) {
allPackages.filter { it in filterPkgs }
} else {
allPackages
}
LogUtil.i(
TAG,
"restoreApps: starting restore of ${packages.size} packages (all=${allPackages.size}) from ${backupDir.absolutePath}",
)
if (packages.isEmpty()) {
LogUtil.w(TAG, "restoreApps: packages list is empty, nothing to restore")
}
val successAtomic = AtomicInteger(0)
val failAtomic = AtomicInteger(0)
val successAtomic = AtomicInteger(0)
val failAtomic = AtomicInteger(0)
val semaphore = Semaphore(2)
supervisorScope {
packages.forEachIndexed { index, pkg ->
launch {
if (!coroutineContext.isActive) return@launch
semaphore.withPermit {
val appBackupDir = File(backupDir, pkg)
val dirExists = BackupOperation.backupPathExists(appBackupDir)
LogUtil.i(TAG, "restoreApps: pkg=$pkg appBackupDir=${appBackupDir.absolutePath} exists=$dirExists")
if (!dirExists) {
failAtomic.incrementAndGet()
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "备份目录不存在"))
return@withPermit
val semaphore = Semaphore(2)
supervisorScope {
packages.forEachIndexed { index, pkg ->
launch {
if (!coroutineContext.isActive) return@launch
semaphore.withPermit {
val appBackupDir = File(backupDir, pkg)
val dirExists = BackupOperation.backupPathExists(appBackupDir)
LogUtil.i(TAG, "restoreApps: pkg=$pkg appBackupDir=${appBackupDir.absolutePath} exists=$dirExists")
if (!dirExists) {
failAtomic.incrementAndGet()
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "备份目录不存在"))
return@withPermit
}
// 1. Install APK
emit(RestoreProgress(index + 1, packages.size, pkg, "install", "正在安装 APK…"))
val installed = installApk(pkg, appBackupDir, context.cacheDir)
LogUtil.i(TAG, "restoreApps: pkg=$pkg installApk result=$installed")
if (!installed) {
failAtomic.incrementAndGet()
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "安装失败"))
return@withPermit
}
// 2. Stop the app before restoring data
RootShell.exec("am force-stop '${pkg.shellEscape()}'")
// 3. Restore data
emit(RestoreProgress(index + 1, packages.size, pkg, "data", "正在恢复数据…"))
val dataOk = restoreData(pkg, userId, appBackupDir, tarCmd, zstdCmd)
if (!dataOk) {
failAtomic.incrementAndGet()
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "数据恢复失败"))
return@withPermit
}
// 4. Restore OBB
emit(RestoreProgress(index + 1, packages.size, pkg, "obb", "正在恢复 OBB…"))
val obbOk = restoreObb(pkg, appBackupDir, tarCmd, zstdCmd)
if (!obbOk) {
Log.w(TAG, "restoreApps: OBB restore failed for $pkg, continuing")
}
// 4.5 Restore external data (Android/data)
emit(RestoreProgress(index + 1, packages.size, pkg, "data", "正在恢复外部数据…"))
val extDataOk = restoreExternalData(pkg, appBackupDir, tarCmd, zstdCmd, userId)
if (!extDataOk) {
Log.w(TAG, "restoreApps: external data restore failed for $pkg, continuing")
}
// 5. Restore SSAID
emit(RestoreProgress(index + 1, packages.size, pkg, "ssaid", "正在恢复 SSAID…"))
restoreSsaid(pkg, appBackupDir, userId)
// 6. Restore permissions
emit(RestoreProgress(index + 1, packages.size, pkg, "permissions", "正在恢复权限…"))
restorePermissions(pkg, appBackupDir)
// 7. Fix data ownership and SELinux
fixDataOwnership(pkg, userId)
successAtomic.incrementAndGet()
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "完成"))
}
// 1. Install APK
emit(RestoreProgress(index + 1, packages.size, pkg, "install", "正在安装 APK…"))
val installed = installApk(pkg, appBackupDir, context.cacheDir)
LogUtil.i(TAG, "restoreApps: pkg=$pkg installApk result=$installed")
if (!installed) {
failAtomic.incrementAndGet()
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "安装失败"))
return@withPermit
}
// 2. Stop the app before restoring data
RootShell.exec("am force-stop '${pkg.shellEscape()}'")
// 3. Restore data
emit(RestoreProgress(index + 1, packages.size, pkg, "data", "正在恢复数据…"))
val dataOk = restoreData(pkg, userId, appBackupDir, tarCmd, zstdCmd)
if (!dataOk) {
failAtomic.incrementAndGet()
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "数据恢复失败"))
return@withPermit
}
// 4. Restore OBB
emit(RestoreProgress(index + 1, packages.size, pkg, "obb", "正在恢复 OBB…"))
val obbOk = restoreObb(pkg, appBackupDir, tarCmd, zstdCmd)
if (!obbOk) {
Log.w(TAG, "restoreApps: OBB restore failed for $pkg, continuing")
}
// 5. Restore SSAID
emit(RestoreProgress(index + 1, packages.size, pkg, "ssaid", "正在恢复 SSAID…"))
restoreSsaid(pkg, appBackupDir, userId)
// 6. Restore permissions
emit(RestoreProgress(index + 1, packages.size, pkg, "permissions", "正在恢复权限…"))
restorePermissions(pkg, appBackupDir)
// 7. Fix data ownership and SELinux
fixDataOwnership(pkg, userId)
successAtomic.incrementAndGet()
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "完成"))
}
}
}
val elapsed = System.currentTimeMillis() - startTime
val successCount = successAtomic.get()
val failCount = failAtomic.get()
LogUtil.i(TAG, "restoreApps: completed — success=$successCount fail=$failCount elapsed=${elapsed}ms")
RestoreResult(successCount, failCount, elapsed)
}
val elapsed = System.currentTimeMillis() - startTime
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(packageName: String, appDir: File, cacheDir: File): Boolean {
private suspend fun installApk(
packageName: String,
appDir: File,
cacheDir: File,
): Boolean {
val apkNames = BackupOperation.listBackupFiles(appDir)
LogUtil.i(TAG, "installApk: $packageName listBackupFiles returned ${apkNames?.size} files: $apkNames")
if (apkNames == null) {
@@ -176,11 +193,14 @@ object RestoreOperation {
for (name in apkFiltered) {
val src = File(appDir, name)
val dst = File(installDir, name)
val copyResult = RootShell.exec("cp '${src.absolutePath.shellEscape()}' '${dst.absolutePath.shellEscape()}' && chmod 644 '${dst.absolutePath.shellEscape()}'")
val copyResult =
RootShell.exec(
"cp '${src.absolutePath.shellEscape()}' '${dst.absolutePath.shellEscape()}' && chmod 644 '${dst.absolutePath.shellEscape()}'",
)
if (copyResult.isSuccess && BackupOperation.backupPathExists(dst) && BackupOperation.backupFileSize(dst) > 0L) {
localApks.add(dst)
} else {
Log.w(TAG, "installApk: failed to copy APK ${name}, skipping")
Log.w(TAG, "installApk: failed to copy APK $name, skipping")
}
}
@@ -188,13 +208,15 @@ object RestoreOperation {
val apkPaths = localApks.joinToString(" ") { it.absolutePath.shellEscape() }
if (localApks.size > 1) {
val result = RootShell.exec("pm install-create -r -t 2>/dev/null")
val sessionId = result.output.lines()
.firstOrNull { it.contains("Success") }
?.substringAfter("[")
?.substringBefore("]")
val sessionId =
result.output
.lines()
.firstOrNull { it.contains("Success") }
?.substringAfter("[")
?.substringBefore("]")
if (sessionId != null) {
for ((i, apk) in localApks.withIndex()) {
val sessionName = if (i == 0) "base.apk" else "split_${i}.apk"
val sessionName = if (i == 0) "base.apk" else "split_$i.apk"
RootShell.exec("pm install-write '${sessionId.shellEscape()}' '$sessionName' '${apk.absolutePath.shellEscape()}'")
}
val commit = RootShell.exec("pm install-commit '${sessionId.shellEscape()}'")
@@ -254,10 +276,21 @@ object RestoreOperation {
return false
}
private suspend fun restoreData(packageName: String, userId: String, appDir: File, tarCmd: String, zstdCmd: String): Boolean {
val fileNames = BackupOperation.listBackupFiles(appDir)
?.filter { it.contains("_data.tar") }
?: run { Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}"); return false }
private suspend fun restoreData(
packageName: String,
userId: String,
appDir: File,
tarCmd: String,
zstdCmd: String,
): Boolean {
val fileNames =
BackupOperation
.listBackupFiles(appDir)
?.filter { it.contains("_data.tar") }
?: run {
Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}")
return false
}
if (fileNames.isEmpty()) {
Log.w(TAG, "restoreData: no _data.tar in ${appDir.name}")
return true
@@ -268,11 +301,13 @@ object RestoreOperation {
var anyExtracted = false
val dataPaths = listOf("/data/data/$packageName", "/data/user_de/$userId/$packageName")
val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup")
val excludeArgs = dataPaths.flatMap { dataPath ->
excludeFolders.flatMap { folder ->
listOf("--exclude='${dataPath.shellEscape()}/$folder'", "--exclude='${dataPath.shellEscape()}/$folder/*'")
}
}.joinToString(" ")
val excludeArgs =
dataPaths
.flatMap { dataPath ->
excludeFolders.flatMap { folder ->
listOf("--exclude='${dataPath.shellEscape()}/$folder'", "--exclude='${dataPath.shellEscape()}/$folder/*'")
}
}.joinToString(" ")
for (archive in dataFiles) {
val archivePath = archive.absolutePath.shellEscape()
@@ -283,15 +318,25 @@ object RestoreOperation {
}
// Build the extract command with exclusion flags
val baseCmd = when {
archive.name.endsWith(".zst") ->
"set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null"
archive.name.endsWith(".gz") ->
"$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null"
archive.name.endsWith(".tar") ->
"$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null"
else -> { Log.w(TAG, "restoreData: unknown archive type ${archive.name}"); continue }
}
val baseCmd =
when {
archive.name.endsWith(".zst") -> {
"set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null"
}
archive.name.endsWith(".gz") -> {
"$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null"
}
archive.name.endsWith(".tar") -> {
"$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null"
}
else -> {
Log.w(TAG, "restoreData: unknown archive type ${archive.name}")
continue
}
}
val result = RootShell.exec(baseCmd)
if (result.isSuccess) {
@@ -306,12 +351,13 @@ object RestoreOperation {
for (dataPath in dataPaths) {
// Try to get the existing context (if the path already existed)
val existingContext = SELinuxUtil.getContext(dataPath)
val context = existingContext ?: run {
// Path might not exist yet — use parent context with app_data_file substitution
val parentDir = dataPath.substringBeforeLast("/")
val parentContext = SELinuxUtil.getContext(parentDir)
parentContext?.replace("system_data_file", "app_data_file")
}
val context =
existingContext ?: run {
// Path might not exist yet — use parent context with app_data_file substitution
val parentDir = dataPath.substringBeforeLast("/")
val parentContext = SELinuxUtil.getContext(parentDir)
parentContext?.replace("system_data_file", "app_data_file")
}
if (context != null) {
Log.d(TAG, "restoreData: restoring SELinux context on $dataPath: $context")
@@ -329,12 +375,16 @@ object RestoreOperation {
* or symbolic links pointing outside the tree.
* Accepts both absolute and relative paths — tar implementations vary.
*/
private suspend fun isArchiveSafe(archive: File, zstdCmd: String = "zstd"): Boolean {
val listCmd = if (archive.name.endsWith(".zst")) {
"set -o pipefail; $zstdCmd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null"
} else {
"tar tf '${archive.absolutePath.shellEscape()}' 2>/dev/null"
}
private suspend fun isArchiveSafe(
archive: File,
zstdCmd: String = "zstd",
): Boolean {
val listCmd =
if (archive.name.endsWith(".zst")) {
"set -o pipefail; $zstdCmd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null"
} else {
"tar tf '${archive.absolutePath.shellEscape()}' 2>/dev/null"
}
var result = RootShell.exec(listCmd)
// Fallback: try without pipefail (some Android shells don't support it)
if (!result.isSuccess && archive.name.endsWith(".zst")) {
@@ -357,34 +407,51 @@ object RestoreOperation {
}
}
private suspend fun restoreObb(packageName: String, appDir: File, tarCmd: String, zstdCmd: String): Boolean {
val obbNames = BackupOperation.listBackupFiles(appDir)
?.filter { it.contains("_obb.tar") }
?: return true
private suspend fun restoreObb(
packageName: String,
appDir: File,
tarCmd: String,
zstdCmd: String,
): Boolean {
val obbNames =
BackupOperation
.listBackupFiles(appDir)
?.filter { it.contains("_obb.tar") }
?: return true
if (obbNames.isEmpty()) return true
val obbFiles = obbNames.map { File(appDir, it) }
// Build exclusion patterns for OBB cache/temp directories
val obbPath = "/storage/emulated/0/Android/obb/$packageName"
val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup", "Backup_*")
val excludeArgs = excludeFolders.joinToString(" ") { "--exclude='${obbPath.shellEscape()}/$it' --exclude='${obbPath.shellEscape()}/$it/*'" }
val excludeArgs =
excludeFolders.joinToString(
" ",
) { "--exclude='${obbPath.shellEscape()}/$it' --exclude='${obbPath.shellEscape()}/$it/*'" }
var anyExtracted = false
for (archive in obbFiles) {
if (!isArchiveSafe(archive, zstdCmd)) continue
val archivePath = archive.absolutePath.shellEscape()
val result = when {
archive.name.endsWith(".zst") -> {
RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null")
val result =
when {
archive.name.endsWith(".zst") -> {
RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null")
}
archive.name.endsWith(".gz") -> {
RootShell.exec("$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null")
}
archive.name.endsWith(".tar") -> {
RootShell.exec("$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null")
}
else -> {
Log.w(TAG, "restoreObb: unknown archive type ${archive.name}")
continue
}
}
archive.name.endsWith(".gz") -> {
RootShell.exec("$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null")
}
archive.name.endsWith(".tar") -> {
RootShell.exec("$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null")
}
else -> { Log.w(TAG, "restoreObb: unknown archive type ${archive.name}"); continue }
}
if (result.isSuccess) {
Log.i(TAG, "restoreObb: extracted ${archive.name}")
anyExtracted = true
@@ -397,12 +464,90 @@ object RestoreOperation {
val gidResult = RootShell.exec("stat -c %g '${obbPath.shellEscape()}' 2>/dev/null")
val gid = gidResult.output.trim().toIntOrNull() ?: 1023 // fallback to media_rw gid
RootShell.exec("chown -R $gid:$gid '${obbPath.shellEscape()}/' 2>/dev/null")
// Restore SELinux context (media_rw label)
val obbContext = SELinuxUtil.getContext(obbPath.substringBeforeLast("/"))
if (obbContext != null) {
SELinuxUtil.chcon(obbContext, obbPath)
Log.i(TAG, "restoreObb: restored SELinux context on $obbPath")
}
Log.i(TAG, "restoreObb: set ownership to $gid:$gid on $obbPath")
return anyExtracted
}
private suspend fun restoreSsaid(packageName: String, appDir: File, userId: String) {
/**
* Restore external app data (/data/media/<userId>/Android/data/<pkg>).
* Extracts _external_data.tar archive to the external data directory.
*/
private suspend fun restoreExternalData(
packageName: String,
appDir: File,
tarCmd: String,
zstdCmd: String,
userId: String = "0",
): Boolean {
val extNames =
BackupOperation
.listBackupFiles(appDir)
?.filter { it.contains("_external_data.tar") }
?: return true
if (extNames.isEmpty()) return true
var anyExtracted = false
for (name in extNames) {
val archive = File(appDir, name)
if (!isArchiveSafe(archive, zstdCmd)) continue
val archivePath = archive.absolutePath.shellEscape()
val result =
when {
name.endsWith(".zst") -> {
RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - -C / 2>/dev/null")
}
name.endsWith(".gz") -> {
RootShell.exec("$tarCmd -xzf '$archivePath' -C / 2>/dev/null")
}
name.endsWith(".tar") -> {
RootShell.exec("$tarCmd -xf '$archivePath' -C / 2>/dev/null")
}
else -> {
Log.w(TAG, "restoreExternalData: unknown archive type ${archive.name}")
continue
}
}
if (result.isSuccess) {
Log.i(TAG, "restoreExternalData: extracted ${archive.name}")
anyExtracted = true
} else {
Log.e(TAG, "restoreExternalData: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}")
}
}
// Fix ownership: same as OBB (media_rw group)
val extPath = "/data/media/$userId/Android/data/$packageName"
val gidResult = RootShell.exec("stat -c %g '${extPath.shellEscape()}' 2>/dev/null")
val gid = gidResult.output.trim().toIntOrNull() ?: 1023
RootShell.exec("chown -R $gid:$gid '${extPath.shellEscape()}/' 2>/dev/null")
// Restore SELinux context
val extContext = SELinuxUtil.getContext(extPath.substringBeforeLast("/"))
if (extContext != null) {
SELinuxUtil.chcon(extContext, extPath)
Log.i(TAG, "restoreExternalData: restored SELinux context on $extPath")
}
Log.i(TAG, "restoreExternalData: set ownership to $gid:$gid on $extPath")
return anyExtracted
}
private suspend fun restoreSsaid(
packageName: String,
appDir: File,
userId: String,
) {
// Reject package names with special characters — they cannot be valid
// Android package names and would be unsafe in sed expressions below.
if (!packageName.matches(Regex("^[a-zA-Z][a-zA-Z0-9._-]*(\\.[a-zA-Z][a-zA-Z0-9._-]*)+$"))) {
@@ -423,12 +568,13 @@ object RestoreOperation {
// 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()
val uid =
uidResult.output
.substringAfter("userId=", "")
.substringBefore(" ")
.substringBefore(",")
.trim()
.toIntOrNull()
if (uid == null) {
Log.w(TAG, "restoreSsaid: could not resolve UID for $packageName")
@@ -437,45 +583,49 @@ object RestoreOperation {
// Try XML-based approach first (more reliable across Android versions)
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
val xmlSuccess = run {
// Check if file exists
val checkResult = RootShell.exec("test -f '$targetFile' && echo 'exists'")
if (!checkResult.output.contains("exists")) {
Log.d(TAG, "restoreSsaid: $targetFile does not exist, will use settings command")
return@run false
}
val xmlSuccess =
run {
// Check if file exists
val checkResult = RootShell.exec("test -f '$targetFile' && echo 'exists'")
if (!checkResult.output.contains("exists")) {
Log.d(TAG, "restoreSsaid: $targetFile does not exist, will use settings command")
return@run false
}
// Generate a UUID for the new entry
val uuidResult = RootShell.exec("cat /proc/sys/kernel/random/uuid 2>/dev/null")
val id = uuidResult.output.trim()
// Strict UUID format check (also keeps the value safe inside the sed string)
if (!id.matches(Regex("^[0-9a-fA-F-]{36}$"))) {
Log.w(TAG, "restoreSsaid: could not generate UUID (got '$id'), falling back")
return@run false
}
// Generate a UUID for the new entry
val uuidResult = RootShell.exec("cat /proc/sys/kernel/random/uuid 2>/dev/null")
val id = uuidResult.output.trim()
// Strict UUID format check (also keeps the value safe inside the sed string)
if (!id.matches(Regex("^[0-9a-fA-F-]{36}$"))) {
Log.w(TAG, "restoreSsaid: could not generate UUID (got '$id'), falling back")
return@run false
}
// Remove existing entry for this package and insert new one before </settings>
val manipCmd = buildString {
append("sed -i \"/package.*${packageName.shellEscape()}/d\" '$targetFile' && ")
append("sed -i \"s#</settings>#<setting id=\\\"$id\\\" package=\\\"${packageName.shellEscape()}\\\" value=\\\"${ssaidValue.shellEscape()}\\\" defaultValue=\\\"default\\\" />\\n</settings>#\" '$targetFile'")
}
val result = RootShell.exec(manipCmd)
if (!result.isSuccess) {
Log.w(TAG, "restoreSsaid: XML edit failed: ${result.error}")
return@run false
}
// Remove existing entry for this package and insert new one before </settings>
val manipCmd =
buildString {
append("sed -i \"/package.*${packageName.shellEscape()}/d\" '$targetFile' && ")
append(
"sed -i \"s#</settings>#<setting id=\\\"$id\\\" package=\\\"${packageName.shellEscape()}\\\" value=\\\"${ssaidValue.shellEscape()}\\\" defaultValue=\\\"default\\\" />\\n</settings>#\" '$targetFile'",
)
}
val result = RootShell.exec(manipCmd)
if (!result.isSuccess) {
Log.w(TAG, "restoreSsaid: XML edit failed: ${result.error}")
return@run false
}
// Verify the package entry was added by checking if it appears in the file now
val verifyCmd = RootShell.exec("grep -c \"${packageName.shellEscape()}\" '$targetFile' 2>/dev/null")
val entryCount = verifyCmd.output.trim().toIntOrNull() ?: 0
if (entryCount > 0) {
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via XML (uid=$uid)")
true
} else {
Log.w(TAG, "restoreSsaid: XML edit completed but entry not found, falling back")
false
// Verify the package entry was added by checking if it appears in the file now
val verifyCmd = RootShell.exec("grep -c \"${packageName.shellEscape()}\" '$targetFile' 2>/dev/null")
val entryCount = verifyCmd.output.trim().toIntOrNull() ?: 0
if (entryCount > 0) {
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via XML (uid=$uid)")
true
} else {
Log.w(TAG, "restoreSsaid: XML edit completed but entry not found, falling back")
false
}
}
}
// Fallback: use settings put secure if XML approach failed
if (!xmlSuccess) {
@@ -488,14 +638,18 @@ object RestoreOperation {
}
}
private suspend fun restorePermissions(packageName: String, appDir: File) {
private suspend fun restorePermissions(
packageName: String,
appDir: File,
) {
val permFile = File(appDir, "permissions.txt")
val content = BackupOperation.readTextFile(permFile) ?: return
val parsedPerms = content.lines().mapNotNull { line ->
val name = line.substringBefore(":").trim().takeIf { it.isNotEmpty() && it.contains(".") } ?: return@mapNotNull null
val granted = line.contains("granted=true")
Pair(name, granted)
}
val parsedPerms =
content.lines().mapNotNull { line ->
val name = line.substringBefore(":").trim().takeIf { it.isNotEmpty() && it.contains(".") } ?: return@mapNotNull null
val granted = line.contains("granted=true")
Pair(name, granted)
}
if (parsedPerms.isEmpty()) return
@@ -532,34 +686,40 @@ object RestoreOperation {
private suspend fun resolveAppUid(packageName: String): Int? {
val pkgEsc = packageName.shellEscape()
// Method 1: pm list packages -U (reliable, consistent output format)
val pmResult = RootShell.exec("pm list packages -U 2>/dev/null | grep '${pkgEsc}$'")
val pmUid = pmResult.output
.substringAfter(" uid:")
.trim()
.toIntOrNull()
val pmResult = RootShell.exec("pm list packages -U 2>/dev/null | grep '$pkgEsc$'")
val pmUid =
pmResult.output
.substringAfter(" uid:")
.trim()
.toIntOrNull()
if (pmUid != null) return pmUid
// Method 2: dumpsys package (fallback for older Android)
val dsResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1")
val dsUid = dsResult.output
.substringAfter("userId=", "")
.substringBefore(" ")
.substringBefore(",")
.trim()
.toIntOrNull()
val dsUid =
dsResult.output
.substringAfter("userId=", "")
.substringBefore(" ")
.substringBefore(",")
.trim()
.toIntOrNull()
if (dsUid != null) return dsUid
// Method 3: dumpsys with userId: separator (AOSP variant)
val ds2Result = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId:' | head -1")
val ds2Uid = ds2Result.output
.substringAfter("userId:", "")
.substringBefore(" ")
.trim()
.toIntOrNull()
val ds2Uid =
ds2Result.output
.substringAfter("userId:", "")
.substringBefore(" ")
.trim()
.toIntOrNull()
return ds2Uid
}
private suspend fun fixDataOwnership(packageName: String, userId: String) {
private suspend fun fixDataOwnership(
packageName: String,
userId: String,
) {
val pkgEsc = packageName.shellEscape()
val uidEsc = userId.shellEscape()
@@ -569,22 +729,27 @@ object RestoreOperation {
return
}
// USER and USER_DE use uid:uid (app's own group)
val dataPaths = listOf(
"/data/data/$pkgEsc",
"/data/user_de/$uidEsc/$pkgEsc"
)
// USER, USER_DE, and external data paths
val dataPaths =
listOf(
"/data/data/$pkgEsc",
"/data/user_de/$uidEsc/$pkgEsc",
"/data/media/$uidEsc/Android/data/$pkgEsc",
"/storage/emulated/0/Android/obb/$pkgEsc",
"/data/media/$uidEsc/Android/obb/$pkgEsc",
)
for (dataPath in dataPaths) {
RootShell.exec("chown -R $uid:$uid '$dataPath/' 2>/dev/null")
// Restore SELinux context instead of using restorecon (which applies defaults)
val existingContext = SELinuxUtil.getContext(dataPath)
val context = existingContext ?: run {
val parentDir = dataPath.substringBeforeLast("/")
val parentContext = SELinuxUtil.getContext(parentDir)
parentContext?.replace("system_data_file", "app_data_file")
}
val context =
existingContext ?: run {
val parentDir = dataPath.substringBeforeLast("/")
val parentContext = SELinuxUtil.getContext(parentDir)
parentContext?.replace("system_data_file", "app_data_file")
}
if (context != null) {
SELinuxUtil.chcon(context, dataPath)
Log.d(TAG, "fixDataOwnership: restored SELinux context on $dataPath: $context")

View File

@@ -1,9 +1,9 @@
package com.example.androidbackupgui.ui
import android.content.Intent
import android.util.Log
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import android.util.Log
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SortByAlpha
@@ -17,10 +17,10 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.example.androidbackupgui.backup.*
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_START_BACKUP
import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_STOP_BACKUP
import com.example.androidbackupgui.backup.BackupService.Companion.EXTRA_STATUS_TEXT
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.WifiManager
import kotlinx.coroutines.Dispatchers
@@ -56,10 +56,11 @@ fun BackupScreen() {
// Re-apply sort/filter when dependencies change
LaunchedEffect(allApps, sortMode, showSystemApps) {
val filtered = if (showSystemApps) allApps else allApps.filter { !it.isSystem }
val sorted = when (sortMode) {
SortMode.NAME_ASC -> filtered.sortedBy { it.label.lowercase(Locale.US) }
SortMode.SIZE_DESC -> filtered.sortedByDescending { it.backupSize }
}
val sorted =
when (sortMode) {
SortMode.NAME_ASC -> filtered.sortedBy { it.label.lowercase(Locale.US) }
SortMode.SIZE_DESC -> filtered.sortedByDescending { it.backupSize }
}
sortedApps = sorted
}
@@ -67,7 +68,6 @@ fun BackupScreen() {
// ── Top controls card ──
Card(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Scan button
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
@@ -77,16 +77,38 @@ fun BackupScreen() {
scope.launch {
try {
val userId = config.backupUserId
val thirdParty = withContext(Dispatchers.IO) {
AppScanner.scanThirdParty(context, userId = userId)
}
val system = withContext(Dispatchers.IO) {
AppScanner.scanSystem(context, config, userId = userId)
}
val thirdParty =
withContext(Dispatchers.IO) {
AppScanner.scanThirdParty(context, userId = userId)
}
val system =
withContext(Dispatchers.IO) {
AppScanner.scanSystem(context, config, userId = userId)
}
val apps = if (showSystemApps) thirdParty + system else thirdParty
allApps = apps
selectedApps = apps.map { it.packageName.value }.toSet()
statusText = "共找到 ${apps.size} 个应用,全部已选中"
val allPkgNames = apps.map { it.packageName.value }.toSet()
selectedApps = allPkgNames
// Check for appList.txt with '!' prefix (no-data-backup markers)
val appListFile = File(context.filesDir, "appList.txt")
if (appListFile.exists()) {
val content = appListFile.readText()
val parsed = AppScanner.parseAppList(content)
val excludeFromPrefix =
parsed
.filter { it.first in allPkgNames && !it.second }
.map { it.first }
.toSet()
if (excludeFromPrefix.isNotEmpty()) {
excludeDataFromBackup = excludeFromPrefix
statusText = "共找到 ${apps.size} 个应用,${excludeFromPrefix.size} 个标记为仅APK"
} else {
statusText = "共找到 ${apps.size} 个应用,全部已选中"
}
} else {
statusText = "共找到 ${apps.size} 个应用,全部已选中"
}
} catch (e: Exception) {
statusText = "扫描应用失败: ${e.message}"
} finally {
@@ -95,7 +117,7 @@ fun BackupScreen() {
}
},
enabled = !isScanning && !isRunning,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
) {
if (isScanning) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
@@ -115,7 +137,7 @@ fun BackupScreen() {
label = { Text("A-Z") },
leadingIcon = {
Icon(Icons.Default.SortByAlpha, contentDescription = null, modifier = Modifier.size(16.dp))
}
},
)
FilterChip(
selected = sortMode == SortMode.SIZE_DESC,
@@ -125,7 +147,7 @@ fun BackupScreen() {
label = { Text("大小") },
leadingIcon = {
Icon(Icons.Default.Storage, contentDescription = null, modifier = Modifier.size(16.dp))
}
},
)
Spacer(Modifier.width(8.dp))
TextButton(onClick = {
@@ -147,14 +169,14 @@ fun BackupScreen() {
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
// ── App list ──
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(sortedApps, key = { it.packageName.value }) { app ->
AppListItem(
@@ -162,13 +184,21 @@ fun BackupScreen() {
isSelected = app.packageName.value in selectedApps,
isDataExcluded = app.packageName.value in excludeDataFromBackup,
onToggle = { checked ->
selectedApps = if (checked) selectedApps + app.packageName.value
else selectedApps - app.packageName.value
selectedApps =
if (checked) {
selectedApps + app.packageName.value
} else {
selectedApps - app.packageName.value
}
},
onExcludeDataToggle = { excluded ->
excludeDataFromBackup = if (excluded) excludeDataFromBackup + app.packageName.value
else excludeDataFromBackup - app.packageName.value
}
excludeDataFromBackup =
if (excluded) {
excludeDataFromBackup + app.packageName.value
} else {
excludeDataFromBackup - app.packageName.value
}
},
)
}
}
@@ -176,31 +206,37 @@ fun BackupScreen() {
// ── Bottom bar with backup button ──
Surface(
modifier = Modifier.fillMaxWidth(),
tonalElevation = 3.dp
tonalElevation = 3.dp,
) {
Button(
onClick = {
val toBackup = allApps.filter { it.packageName.value in selectedApps }
if (toBackup.isEmpty()) return@Button
isRunning = true
statusText = "开始备份 ${toBackup.size} 个应用…"
if (toBackup.isEmpty()) return@Button
isRunning = true
statusText = "开始备份 ${toBackup.size} 个应用…"
scope.launch {
try {
// 1. Start foreground service
val serviceIntent = Intent(context, BackupService::class.java).apply {
scope.launch {
try {
// 1. Start foreground service
val serviceIntent =
Intent(context, BackupService::class.java).apply {
action = ACTION_START_BACKUP
putExtra(EXTRA_STATUS_TEXT, "正在备份 ${toBackup.size} 个应用…")
}
try {
ContextCompat.startForegroundService(context, serviceIntent)
} catch (_: Exception) {}
try {
ContextCompat.startForegroundService(context, serviceIntent)
} catch (_: Exception) {
}
// 2. Execute backup
val outputDir = File(config.outputPath.ifEmpty {
context.filesDir.absolutePath
})
val backupResult = withContext(Dispatchers.IO) {
// 2. Execute backup
val outputDir =
File(
config.outputPath.ifEmpty {
context.filesDir.absolutePath
},
)
val backupResult =
withContext(Dispatchers.IO) {
BackupOperation.backupApps(
context = context,
apps = toBackup,
@@ -209,27 +245,30 @@ fun BackupScreen() {
userId = config.backupUserId.toString(),
noDataBackup = excludeDataFromBackup,
onProgress = { progress ->
statusText = "[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
}
statusText =
"[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
},
)
}
statusText = "备份完成!成功: ${backupResult.successCount} 失败: ${backupResult.failCount} 耗时: ${backupResult.elapsedMs / 1000}s"
statusText =
"备份完成!成功: ${backupResult.successCount} 失败: ${backupResult.failCount} 耗时: ${backupResult.elapsedMs / 1000}s"
// 3. WiFi 备份
WifiManager.backup(File(backupResult.outputDir))
// 3. WiFi 备份
WifiManager.backup(File(backupResult.outputDir))
// 4. Restic 上传(如启用)
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
val binaryPath = ResticBinary.prepare(context)
if (binaryPath != null) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.cacheDir = context.cacheDir.absolutePath
ResticWrapper.backendDomain = config.resticBackendDomain
// 4. Restic 上传(如启用)
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
val binaryPath = ResticBinary.prepare(context)
if (binaryPath != null) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.cacheDir = context.cacheDir.absolutePath
ResticWrapper.backendDomain = config.resticBackendDomain
if (config.useStreaming == 1) {
// ── Streaming path ──
statusText = "正在流式备份到 restic 去重仓库…"
val resticResult = withContext(Dispatchers.IO) {
if (config.useStreaming == 1) {
// ── Streaming path ──
statusText = "正在流式备份到 restic 去重仓库…"
val resticResult =
withContext(Dispatchers.IO) {
ResticWrapper.backupStreaming(
apps = toBackup,
noDataBackup = excludeDataFromBackup,
@@ -244,13 +283,14 @@ fun BackupScreen() {
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onProgress = { msg -> statusText = msg }
onProgress = { msg -> statusText = msg },
)
}
when (resticResult) {
is AppResult.Success -> {
val summary = resticResult.getOrNull()
statusText = buildString {
when (resticResult) {
is AppResult.Success -> {
val summary = resticResult.getOrNull()
statusText =
buildString {
appendLine("流式备份完成!")
appendLine("Restic ID: ${summary?.snapshotId?.take(8)}")
if (summary != null) {
@@ -258,15 +298,17 @@ fun BackupScreen() {
appendLine("文件: ${summary.totalFilesProcessed}")
}
}
}
is AppResult.Failure -> {
statusText = "流式备份失败: ${resticResult.errorOrNull()?.message}"
}
}
} else {
// ── Standard path (staging dir) ──
statusText = "正在写入 restic 去重仓库…"
val resticResult = withContext(Dispatchers.IO) {
is AppResult.Failure -> {
statusText = "流式备份失败: ${resticResult.errorOrNull()?.message}"
}
}
} else {
// ── Standard path (staging dir) ──
statusText = "正在写入 restic 去重仓库…"
val resticResult =
withContext(Dispatchers.IO) {
ResticWrapper.backup(
repoPath = config.resticRepo,
password = config.resticPassword,
@@ -280,19 +322,21 @@ fun BackupScreen() {
backendShare = config.resticBackendShare,
onProgress = { progress ->
if (progress.messageType == "status") {
statusText = "去重仓库: %.0f%% (%d/%d 个文件)".format(
progress.percentDone * 100,
progress.filesDone,
progress.totalFiles
)
statusText =
"去重仓库: %.0f%% (%d/%d 个文件)".format(
progress.percentDone * 100,
progress.filesDone,
progress.totalFiles,
)
}
}
},
)
}
when (resticResult) {
is AppResult.Success -> {
val summary = resticResult.getOrNull()
statusText = buildString {
when (resticResult) {
is AppResult.Success -> {
val summary = resticResult.getOrNull()
statusText =
buildString {
appendLine("备份完成!")
appendLine("成功: ${backupResult.successCount} 失败: ${backupResult.failCount}")
appendLine("耗时: ${backupResult.elapsedMs / 1000}")
@@ -301,39 +345,52 @@ fun BackupScreen() {
appendLine("新增: ${summary.dataAdded / 1024 / 1024} MB")
}
}
}
is AppResult.Failure -> {
statusText = "restic 快照失败: ${resticResult.errorOrNull()?.message}"
}
}
is AppResult.Failure -> {
statusText = "restic 快照失败: ${resticResult.errorOrNull()?.message}"
}
}
}
}
} catch (e: Exception) {
val errMsg = e.message ?: "未知错误"
Log.e("BackupScreen", "备份异常", e)
val hint = when {
errMsg.contains("EPERM", ignoreCase = true) || errMsg.contains("Operation not permitted", ignoreCase = true) ->
"写入备份目录被拒绝,请检查输出路径权限或改用内置存储"
errMsg.contains("EACCES", ignoreCase = true) || errMsg.contains("Permission denied", ignoreCase = true) ->
"权限不足,请检查存储权限"
else -> null
}
statusText = if (hint != null) "备份异常: ${e.message} ($hint)" else "备份异常: ${e.message}"
}
finally {
isRunning = false
try {
val stopIntent = Intent(context, BackupService::class.java).apply {
} catch (e: Exception) {
val errMsg = e.message ?: "未知错误"
Log.e("BackupScreen", "备份异常", e)
val hint =
when {
errMsg.contains("EPERM", ignoreCase = true) ||
errMsg.contains("Operation not permitted", ignoreCase = true) -> {
"写入备份目录被拒绝,请检查输出路径权限或改用内置存储"
}
errMsg.contains(
"EACCES",
ignoreCase = true,
) || errMsg.contains("Permission denied", ignoreCase = true) -> {
"权限不足,请检查存储权限"
}
else -> {
null
}
}
statusText = if (hint != null) "备份异常: ${e.message} ($hint)" else "备份异常: ${e.message}"
} finally {
isRunning = false
try {
val stopIntent =
Intent(context, BackupService::class.java).apply {
action = ACTION_STOP_BACKUP
}
context.startService(stopIntent)
} catch (_: Exception) {}
context.startService(stopIntent)
} catch (_: Exception) {
}
}
},
}
},
enabled = !isRunning && selectedApps.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(12.dp)
modifier = Modifier.fillMaxWidth().padding(12.dp),
) {
if (isRunning) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
@@ -351,27 +408,27 @@ private fun AppListItem(
isSelected: Boolean,
isDataExcluded: Boolean,
onToggle: (Boolean) -> Unit,
onExcludeDataToggle: (Boolean) -> Unit
onExcludeDataToggle: (Boolean) -> Unit,
) {
Card(
onClick = { onToggle(!isSelected) },
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = isSelected, onCheckedChange = { onToggle(it) })
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = app.label.ifEmpty { app.packageName.value },
style = MaterialTheme.typography.bodyLarge
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = app.packageName.value,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (isSelected) {
@@ -379,8 +436,12 @@ private fun AppListItem(
Text(
"数据",
textDecoration = if (isDataExcluded) TextDecoration.LineThrough else TextDecoration.None,
color = if (isDataExcluded) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.primary
color =
if (isDataExcluded) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
},
)
}
}