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:start -->
# GitNexus — Code Intelligence # 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. > 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:start -->
# GitNexus — Code Intelligence # 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. > 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 package com.example.androidbackupgui.backup
import android.util.Log
import com.example.androidbackupgui.backup.ResticWrapper.SnapshotAppInfo import com.example.androidbackupgui.backup.ResticWrapper.SnapshotAppInfo
import com.example.androidbackupgui.root.RootShell import com.example.androidbackupgui.root.RootShell
import android.util.Log
import com.example.androidbackupgui.root.shellEscape import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.Dispatchers 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.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit 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 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. * Mirrors the logic from backup_script's modules/backup.sh.
*/ */
object BackupOperation { object BackupOperation {
private const val TAG = "BackupOperation" private const val TAG = "BackupOperation"
@Serializable @Serializable
@@ -31,8 +31,8 @@ object BackupOperation {
val current: Int, val current: Int,
val total: Int, val total: Int,
val packageName: String, val packageName: String,
val stage: String, // "apk", "data", "obb", "ssaid", "done" val stage: String, // "apk", "data", "obb", "ssaid", "done"
val message: String val message: String,
) )
@Serializable @Serializable
@@ -41,7 +41,7 @@ object BackupOperation {
val failCount: Int, val failCount: Int,
val skippedCount: Int, val skippedCount: Int,
val outputDir: String, val outputDir: String,
val elapsedMs: Long val elapsedMs: Long,
) )
/** /**
@@ -65,143 +65,277 @@ object BackupOperation {
noDataBackup: Set<String> = emptySet(), noDataBackup: Set<String> = emptySet(),
includePkgs: Set<String> = emptySet(), includePkgs: Set<String> = emptySet(),
legacyApps: Map<String, SnapshotAppInfo>? = null, legacyApps: Map<String, SnapshotAppInfo>? = null,
onProgress: suspend (BackupProgress) -> Unit = {} onProgress: suspend (BackupProgress) -> Unit = {},
): BackupResult = withContext(Dispatchers.IO) { ): BackupResult =
val emit: suspend (BackupProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } } withContext(Dispatchers.IO) {
val startTime = System.currentTimeMillis() val emit: suspend (BackupProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
val startTime = System.currentTimeMillis()
// Create backup structure // Safety check: refuse to backup inside Android/data directories
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId") val absOut = outputDir.absolutePath
if (!mkdirsForBackup(backupRoot)) { if (absOut.contains("/Android/")) {
LogUtil.e(TAG, "backupApps: cannot create output dir ${backupRoot.absolutePath}") LogUtil.e(TAG, "backupApps: refusing to backup inside Android/ directory: $absOut")
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0) return@withContext BackupResult(0, 0, 0, absOut, 0)
} }
LogUtil.i(TAG, "backupApps: starting backup of ${apps.size} apps to ${backupRoot.absolutePath}")
// Write app list — includes ALL packages in [apps] (selected + legacy from snapshot) // Create backup structure
val appListFile = File(backupRoot, "appList.txt") val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
if (!writeFileForBackup(appListFile, apps.joinToString("\n") { it.packageName.value })) { if (!mkdirsForBackup(backupRoot)) {
LogUtil.e(TAG, "backupApps: failed to write appList.txt") LogUtil.e(TAG, "backupApps: cannot create output dir ${backupRoot.absolutePath}")
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0) 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 // Read previous metadata for incremental backup comparison
val metaFile = File(backupRoot, "app_details.json") val oldMetaFile = File(backupRoot, "app_details.json")
if (!writeFileForBackup(metaFile, buildAppDetailsJson(apps, legacyApps))) { val oldMetaJson =
LogUtil.e(TAG, "backupApps: failed to write app_details.json") if (oldMetaFile.exists()) {
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0) try {
} JSONObject(readTextFile(oldMetaFile) ?: "{}")
} catch (_: Exception) {
val backupTargets = if (includePkgs.isEmpty()) apps else apps.filter { it.packageName.value in includePkgs } JSONObject()
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", "完成"))
} }
} 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}'") * Backup user data (/data/data + /data/user_de).
* @return Pair(userSize, userDeSize) or null for the failing one.
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
)
}
private suspend fun backupUserData( private suspend fun backupUserData(
context: android.content.Context, context: android.content.Context,
packageName: String, packageName: String,
appDir: File, appDir: File,
userId: String, userId: String,
compression: String compression: String,
): Boolean { ): Pair<Long?, Long?> {
val pkgEsc = packageName.shellEscape() val pkgEsc = packageName.shellEscape()
val outputFile = "${appDir.absolutePath.shellEscape()}/${pkgEsc}_data.tar" 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 // Helper: check file exists and has size > 0, using root shell for FUSE paths
suspend fun archiveHasData(): Boolean = suspend fun archiveHasData(): Boolean =
BackupOperation.backupPathExists(archiveRaw) && 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)") Log.d(TAG, "backupUserData: $packageName checking dirs (tar=$tarCmd zstd=$zstdCmd)")
@@ -254,11 +388,16 @@ object BackupOperation {
if (!archiveCreated) { if (!archiveCreated) {
Log.w(TAG, "backupUserData: $packageName step3 trying /proc/1/root") Log.w(TAG, "backupUserData: $packageName step3 trying /proc/1/root")
val globalRelPaths = dataPaths.map { it.removePrefix("/") } val globalRelPaths = dataPaths.map { it.removePrefix("/") }
val globalCmd = if (isZstd) { val globalCmd =
"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'" if (isZstd) {
} else { "cd /proc/1/root && set -o pipefail; $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(
"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" " ",
} ) { "'${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) result = RootShell.exec(globalCmd)
archiveCreated = archiveHasData() archiveCreated = archiveHasData()
Log.d(TAG, "backupUserData: $packageName step3 exit=${result?.exitCode} err='${result?.error?.take(100)}'") Log.d(TAG, "backupUserData: $packageName step3 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
@@ -266,31 +405,33 @@ object BackupOperation {
if (!archiveCreated) { if (!archiveCreated) {
LogUtil.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)") LogUtil.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)")
return false return null to null
} }
// Verify compression integrity // Verify compression integrity
val verifyOk = if (isZstd) { val verifyOk =
RootShell.exec("$zstdCmd -t '$outputFile.zst' 2>/dev/null").isSuccess if (isZstd) {
} else { RootShell.exec("$zstdCmd -t '$outputFile.zst' 2>/dev/null").isSuccess
RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess } else {
} RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess
}
if (!verifyOk) { if (!verifyOk) {
Log.e(TAG, "backupUserData: $packageName integrity check FAILED") Log.e(TAG, "backupUserData: $packageName integrity check FAILED")
return false return null to null
} }
// Validate tar archive structure // Validate tar archive structure
val tarValidateOk = if (isZstd) { val tarValidateOk =
RootShell.exec("$zstdCmd -d -c '$outputFile.zst' 2>/dev/null | tar -tf - > /dev/null 2>&1").isSuccess if (isZstd) {
} else { RootShell.exec("$zstdCmd -d -c '$outputFile.zst' 2>/dev/null | tar -tf - > /dev/null 2>&1").isSuccess
RootShell.exec("tar -tf '$outputFile.gz' > /dev/null 2>&1").isSuccess } else {
} RootShell.exec("tar -tf '$outputFile.gz' > /dev/null 2>&1").isSuccess
}
if (!tarValidateOk) { if (!tarValidateOk) {
Log.e(TAG, "backupUserData: $packageName tar archive structure validation FAILED") 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. */ /** Run tar for given paths, building the appropriate zstd/gzip command. */
@@ -300,58 +441,160 @@ object BackupOperation {
isZstd: Boolean, isZstd: Boolean,
tarCmd: String = "tar", tarCmd: String = "tar",
zstdCmd: String = "zstd", zstdCmd: String = "zstd",
excludes: List<String> = emptyList() excludes: List<String> = emptyList(),
): RootShell.ShellResult { ): RootShell.ShellResult {
val excludeArgs = if (excludes.isNotEmpty()) { val excludeArgs =
excludes.joinToString(" ") { "--exclude='${it.shellEscape()}'" } if (excludes.isNotEmpty()) {
} else "" excludes.joinToString(" ") { "--exclude='${it.shellEscape()}'" }
} else {
""
}
return if (isZstd) { 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 { } else {
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null") 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 obbDir = "/storage/emulated/0/Android/obb/${packageName.shellEscape()}"
val escapedAppDir = appDir.absolutePath.shellEscape() val escapedAppDir = appDir.absolutePath.shellEscape()
val escapedPkg = packageName.shellEscape() val escapedPkg = packageName.shellEscape()
// Exclude cache and backup temp files from OBB archive // Exclude cache and backup temp files from OBB archive
val obbExcludes = "--exclude='cache' --exclude='Backup_*'" val obbExcludes = "--exclude='cache' --exclude='Backup_*'"
val result = when (compression) { val result =
"zstd" -> RootShell.exec("set -o pipefail; tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'") when (compression) {
else -> RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null") "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) { if (!result.isSuccess) {
Log.e(TAG, "Failed to backup OBB for $packageName: exit=${result.exitCode} err=${result.error}") 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 obbArchiveExt = if (compression == "zstd") ".zst" else ".gz"
val verifyCmd = if (compression == "zstd") "zstd -t '$archive' 2>/dev/null" else "gzip -t '$archive' 2>/dev/null" 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 val verificationOk = RootShell.exec(verifyCmd).isSuccess
if (!verificationOk) { if (!verificationOk) {
Log.e(TAG, "OBB archive integrity check FAILED for $packageName") Log.e(TAG, "OBB archive integrity check FAILED for $packageName")
} }
// Validate OBB tar structure // 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 val tarOk = RootShell.exec(tarListCmd).isSuccess
if (!tarOk) { if (!tarOk) {
Log.e(TAG, "OBB tar structure validation FAILED for $packageName") 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" val ssaidFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
// Parse XML value attribute for this package's SSAID entry // Parse XML value attribute for this package's SSAID entry
val result = RootShell.exec("cat '$ssaidFile' 2>/dev/null") val result = RootShell.exec("cat '$ssaidFile' 2>/dev/null")
if (!result.isSuccess || result.output.isBlank()) return if (!result.isSuccess || result.output.isBlank()) return
val ssaidLine = result.output.lines().firstOrNull { line -> val ssaidLine =
line.contains("packageName=\"$packageName\"") || line.contains("packageName='$packageName'") result.output.lines().firstOrNull { line ->
} line.contains("packageName=\"$packageName\"") || line.contains("packageName='$packageName'")
val value = ssaidLine }
?.substringAfter("value=\"") val value =
?.substringBefore("\"") ssaidLine
?.takeIf { it.isNotBlank() } ?.substringAfter("value=\"")
?.substringBefore("\"")
?.takeIf { it.isNotBlank() }
if (value != null) { if (value != null) {
val ssaidFile = File(appDir, "ssaid.txt") val ssaidFile = File(appDir, "ssaid.txt")
if (!writeFileForBackup(ssaidFile, value)) { 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)'") val result = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep -E 'granted=(true|false)'")
if (result.output.isNotBlank()) { if (result.output.isNotBlank()) {
val permFile = File(appDir, "permissions.txt") val permFile = File(appDir, "permissions.txt")
@@ -374,24 +620,66 @@ object BackupOperation {
internal suspend fun buildAppDetailsJson( internal suspend fun buildAppDetailsJson(
apps: List<AppInfo>, apps: List<AppInfo>,
legacyApps: Map<String, SnapshotAppInfo>? = null legacyApps: Map<String, SnapshotAppInfo>? = null,
perAppExtra: Map<String, PerAppExtra>? = null,
): String { ): String {
val root = JSONObject() 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) { for (app in apps) {
val entry = JSONObject() val entry = JSONObject()
entry.put("label", app.label) entry.put("label", app.label)
entry.put("isSystem", app.isSystem) 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 paths = AppScanner.getApkPaths(app.packageName.value)
val sizes = paths.map { path -> val sizes =
val result = RootShell.exec("stat -c%s '${path.shellEscape()}'") paths.map { path ->
if (result.isSuccess) result.output.trim().toLongOrNull() ?: 0L else 0L val result = RootShell.exec("stat -c%s '${path.shellEscape()}'")
} if (result.isSuccess) result.output.trim().toLongOrNull() ?: 0L else 0L
}
entry.put("apkSizes", JSONArray(sizes)) 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) 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() val legacyMap = legacyApps ?: emptyMap()
for ((pkg, legacy) in legacyMap) { for ((pkg, legacy) in legacyMap) {
if (!root.has(pkg)) { if (!root.has(pkg)) {
@@ -405,6 +693,19 @@ object BackupOperation {
return root.toString(2) 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]. */ /** Create backup output directory, falling back to root shell [mkdir -p]. */
internal suspend fun mkdirsForBackup(dir: File): Boolean { internal suspend fun mkdirsForBackup(dir: File): Boolean {
if (dir.isDirectory) return true if (dir.isDirectory) return true
@@ -414,12 +715,17 @@ object BackupOperation {
} }
/** Write text to a file, falling back to root shell (base64 + cat). */ /** 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 { try {
mkdirsForBackup(file.parentFile ?: return false) mkdirsForBackup(file.parentFile ?: return false)
file.writeText(text) file.writeText(text)
return true return true
} catch (_: Exception) { /* fall through */ } } catch (_: Exception) {
// fall through
}
try { try {
mkdirsForBackup(file.parentFile ?: return false) mkdirsForBackup(file.parentFile ?: return false)
val b64 = android.util.Base64.encodeToString(text.toByteArray(), android.util.Base64.NO_WRAP) 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? { internal suspend fun readTextFile(file: File): String? {
try { try {
if (file.exists()) return file.readText() if (file.exists()) return file.readText()
} catch (_: Exception) { /* fall through */ } } catch (_: Exception) {
// fall through
}
try { try {
val result = RootShell.exec("cat '${file.absolutePath.shellEscape()}' 2>/dev/null") val result = RootShell.exec("cat '${file.absolutePath.shellEscape()}' 2>/dev/null")
if (result.isSuccess && result.output.isNotBlank()) return result.output if (result.isSuccess && result.output.isNotBlank()) return result.output
} catch (_: Exception) { /* fall through */ } } catch (_: Exception) {
// fall through
}
return null return null
} }
/** Check if a path is a directory, falling back to root shell [test -d]. */ /** Check if a path is a directory, falling back to root shell [test -d]. */
internal suspend fun backupIsDirectory(dir: File): Boolean { internal suspend fun backupIsDirectory(dir: File): Boolean {
if (dir.isDirectory()) return true if (dir.isDirectory()) return true
@@ -477,11 +786,15 @@ object BackupOperation {
val names = javaFiles.map { it.name } val names = javaFiles.map { it.name }
if (names.isNotEmpty()) return names if (names.isNotEmpty()) return names
} }
} catch (_: Exception) { /* fall through */ } } catch (_: Exception) {
// fall through
}
try { try {
val result = RootShell.exec("ls -1 '${dir.absolutePath.shellEscape()}' 2>/dev/null") val result = RootShell.exec("ls -1 '${dir.absolutePath.shellEscape()}' 2>/dev/null")
if (!result.isSuccess || result.output.isBlank()) return null if (!result.isSuccess || result.output.isBlank()) return null
return result.output.lines().filter { it.isNotBlank() } 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.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import java.io.File
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.File
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
/** /**
@@ -22,7 +22,6 @@ import kotlin.coroutines.coroutineContext
* Only invoked when [BackupConfig.useStreaming] is enabled. * Only invoked when [BackupConfig.useStreaming] is enabled.
*/ */
object ResticStreamBackup { object ResticStreamBackup {
private const val TAG = "ResticStreamBackup" private const val TAG = "ResticStreamBackup"
private const val TAR_TIMEOUT_MS = 120_000L private const val TAR_TIMEOUT_MS = 120_000L
@@ -47,249 +46,283 @@ object ResticStreamBackup {
backendUser: String, backendUser: String,
backendPass: String, backendPass: String,
backendShare: String, backendShare: String,
onProgress: suspend (String) -> Unit = {} onProgress: suspend (String) -> Unit = {},
): AppResult<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) { ): AppResult<ResticWrapper.BackupSummary> =
val emit: suspend (String) -> Unit = { msg -> withContext(Dispatchers.Main) { onProgress(msg) } } withContext(Dispatchers.IO) {
val emit: suspend (String) -> Unit = { msg -> withContext(Dispatchers.Main) { onProgress(msg) } }
cacheDir.mkdirs() cacheDir.mkdirs()
// ── 1. Create FIFO ──────────────────────────── // ── 1. Create FIFO ────────────────────────────
val fifo = File(cacheDir, "stream_data.fifo") val fifo = File(cacheDir, "stream_data.fifo")
if (fifo.exists()) RootShell.exec("rm -f '${fifo.absolutePath.shellEscape()}'") if (fifo.exists()) RootShell.exec("rm -f '${fifo.absolutePath.shellEscape()}'")
val mkfifoResult = RootShell.exec("mkfifo '${fifo.absolutePath.shellEscape()}'") val mkfifoResult = RootShell.exec("mkfifo '${fifo.absolutePath.shellEscape()}'")
if (!mkfifoResult.isSuccess) { if (!mkfifoResult.isSuccess) {
LogUtil.e(TAG, "backup: mkfifo failed: ${mkfifoResult.error}") LogUtil.e(TAG, "backup: mkfifo failed: ${mkfifoResult.error}")
return@withContext err(AppError.Config("无法创建数据管道 (mkfifo)")) return@withContext err(AppError.LocalIO("无法创建数据管道 (mkfifo)", fifo.absolutePath))
}
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))
} }
Log.i(TAG, "Collected ${apkPaths.size} APK paths") Log.i(TAG, "FIFO created at ${fifo.absolutePath}")
// ── 4. Build restic env and args ────────── try {
val extraArgs = mutableListOf<String>() // ── 2. Write metadata ─────────────────────
extraArgs.addAll(apkPaths) val metaDir = File(cacheDir, "stream_meta")
extraArgs.add(metaDir.absolutePath) 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") // ── 3. Collect APK paths ──────────────────
for (path in extraArgs) args.add(path) val apkPaths = mutableListOf<String>()
for (tag in tags) { args.add("--tag"); args.add(tag) } for (app in apps) {
if (hostname != null) { args.add("--host"); args.add(hostname) } 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) // ── 4. Build restic env and args ──────────
val env = if (backend == "local") { val extraArgs = mutableListOf<String>()
restic.envResolver.buildLocalEnv(repoPath, password, restic.cacheDir) extraArgs.addAll(apkPaths)
} else { extraArgs.add(metaDir.absolutePath)
// 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("流式备份暂不支持远程后端,请使用本地仓库"))
}
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 ── val cmdArgs = restic.runner.buildCommandArgs(args)
var backupSummary: ResticWrapper.BackupSummary? = null val env =
var backupError: AppError? = null if (backend == "local") {
var consumerDone = false 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 { emit("流式备份开始 (${apps.size} 个应用)")
// 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()
// Daemon thread: pipe FIFO → process stdin // ── 5. Consumer + Producer in coroutineScope ──
val stdinThread = Thread { 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 { try {
java.io.FileInputStream(fifo).use { fis -> Log.i(TAG, "Consumer: starting restic ${cmdArgs.joinToString(" ")}")
process.outputStream.use { pos -> val pb = ProcessBuilder(cmdArgs)
fis.copyTo(pos) 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"
} }
} stdinThread.start()
} catch (_: Exception) {
// FIFO writer closed or process exited
}
}.apply { isDaemon = true; name = "restic-stdin-pipe" }
stdinThread.start()
// Drain stderr on a separate daemon thread to avoid pipe deadlock // Drain stderr on a separate daemon thread to avoid pipe deadlock
var stderrBytes = byteArrayOf() var stderrBytes = byteArrayOf()
val stderrThread = Thread { val stderrThread =
try { Thread {
stderrBytes = process.errorStream.use { it.readAllBytesCompat() } try {
} catch (_: Exception) { stderrBytes = process.errorStream.use { it.readAllBytesCompat() }
// stream closed early } catch (_: Exception) {
} // stream closed early
}.apply { isDaemon = true; name = "restic-stderr-drain" } }
stderrThread.start() }.apply {
isDaemon = true
name = "restic-stderr-drain"
}
stderrThread.start()
// Read stdout line by line // Read stdout line by line
val stdoutLines = mutableListOf<String>() val stdoutLines = mutableListOf<String>()
val reader = process.inputStream.bufferedReader() 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
try { try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line) var line = reader.readLine()
if (progress.messageType == "status") { while (line != null) {
val pct = "%.1f".format(progress.percentDone * 100) if (!coroutineContext.isActive) {
emit("备份进度: $pct% (${progress.filesDone}/${progress.totalFiles} 文件)") 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() // Producer: tar each app → FIFO
try { stdinThread.join(2_000) } catch (_: InterruptedException) {} val producerJob =
try { stderrThread.join(1_000) } catch (_: InterruptedException) {} launch {
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()}'"
try { try {
withTimeout(TAR_TIMEOUT_MS) { // Small delay so consumer has time to start reading the FIFO
val result = RootShell.exec(cmd) delay(200)
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++ 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") // Wait for both to complete (producer finishes first, then consumer)
} catch (e: CancellationException) { producerJob.join()
// Normal cancellation consumerJob.join()
} catch (e: Exception) {
LogUtil.e(TAG, "Producer: exception: ${e.message}")
}
} }
// Wait for both to complete (producer finishes first, then consumer) // ── 6. Result ──────────────────────────────
producerJob.join() backupSummary?.let { summary ->
consumerJob.join() 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 package com.example.androidbackupgui.backup
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import java.util.concurrent.atomic.AtomicInteger import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.io.File
import java.util.concurrent.atomic.AtomicInteger
/** /**
* Performs restore of backed-up apps using root shell. * Performs restore of backed-up apps using root shell.
* Mirrors the logic from backup_script's modules/restore.sh. * Mirrors the logic from backup_script's modules/restore.sh.
*/ */
object RestoreOperation { object RestoreOperation {
private const val TAG = "RestoreOperation" private const val TAG = "RestoreOperation"
@Serializable @Serializable
@@ -28,15 +27,15 @@ object RestoreOperation {
val current: Int, val current: Int,
val total: Int, val total: Int,
val packageName: String, val packageName: String,
val stage: String, // "install", "data", "obb", "ssaid", "permissions", "done" val stage: String, // "install", "data", "obb", "ssaid", "permissions", "done"
val message: String val message: String,
) )
@Serializable @Serializable
data class RestoreResult( data class RestoreResult(
val successCount: Int, val successCount: Int,
val failCount: Int, val failCount: Int,
val elapsedMs: Long val elapsedMs: Long,
) )
/** /**
@@ -48,117 +47,135 @@ object RestoreOperation {
backupDir: File, backupDir: File,
userId: String = "0", userId: String = "0",
filterPkgs: Set<String>? = null, filterPkgs: Set<String>? = null,
onProgress: suspend (RestoreProgress) -> Unit = {} onProgress: suspend (RestoreProgress) -> Unit = {},
): RestoreResult = withContext(Dispatchers.IO) { ): RestoreResult =
val emit: suspend (RestoreProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } } withContext(Dispatchers.IO) {
val startTime = System.currentTimeMillis() 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) // Resolve bundled binary paths for tar/zstd (backup used them, restore must too)
val tarCmd = BinaryResolver.tarPath(context) ?: "tar" val tarCmd = BinaryResolver.tarPath(context) ?: "tar"
val bundledZstd = BinaryResolver.zstdPath(context) val bundledZstd = BinaryResolver.zstdPath(context)
val zstdCmd = bundledZstd ?: "zstd" val zstdCmd = bundledZstd ?: "zstd"
// Read app list from backup // Read app list from backup
val appListFile = File(backupDir, "appList.txt") val appListFile = File(backupDir, "appList.txt")
val appListContent = BackupOperation.readTextFile(appListFile) val appListContent = BackupOperation.readTextFile(appListFile)
LogUtil.i(TAG, "restoreApps: appListContent=${appListContent?.substringBefore("\n")?.take(100)}") LogUtil.i(TAG, "restoreApps: appListContent=${appListContent?.substringBefore("\n")?.take(100)}")
val allPackages = appListContent?.let { content -> val allPackages =
content.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") } appListContent?.let { content ->
} ?: run { content.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
LogUtil.i(TAG, "restoreApps: readTextFile returned null, trying listBackupFiles") } ?: run {
val children = BackupOperation.listBackupFiles(backupDir) LogUtil.i(TAG, "restoreApps: readTextFile returned null, trying listBackupFiles")
LogUtil.i(TAG, "restoreApps: listBackupFiles returned ${children?.size} children") val children = BackupOperation.listBackupFiles(backupDir)
children?.filter { name -> LogUtil.i(TAG, "restoreApps: listBackupFiles returned ${children?.size} children")
val apkFile = File(File(backupDir, name), "${name}.apk") children?.filter { name ->
val exists = BackupOperation.backupPathExists(apkFile) val apkFile = File(File(backupDir, name), "$name.apk")
LogUtil.i(TAG, "restoreApps: child $name apkExists=$exists") val exists = BackupOperation.backupPathExists(apkFile)
exists LogUtil.i(TAG, "restoreApps: child $name apkExists=$exists")
} ?: emptyList() exists
} } ?: emptyList()
}
val packages = if (filterPkgs != null) { val packages =
allPackages.filter { it in filterPkgs } if (filterPkgs != null) {
} else { allPackages.filter { it in filterPkgs }
allPackages } else {
} allPackages
LogUtil.i(TAG, "restoreApps: starting restore of ${packages.size} packages (all=${allPackages.size}) from ${backupDir.absolutePath}") }
if (packages.isEmpty()) { LogUtil.i(
LogUtil.w(TAG, "restoreApps: packages list is empty, nothing to restore") 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 successAtomic = AtomicInteger(0)
val failAtomic = AtomicInteger(0) val failAtomic = AtomicInteger(0)
val semaphore = Semaphore(2) val semaphore = Semaphore(2)
supervisorScope { supervisorScope {
packages.forEachIndexed { index, pkg -> packages.forEachIndexed { index, pkg ->
launch { launch {
if (!coroutineContext.isActive) return@launch if (!coroutineContext.isActive) return@launch
semaphore.withPermit { semaphore.withPermit {
val appBackupDir = File(backupDir, pkg) val appBackupDir = File(backupDir, pkg)
val dirExists = BackupOperation.backupPathExists(appBackupDir) val dirExists = BackupOperation.backupPathExists(appBackupDir)
LogUtil.i(TAG, "restoreApps: pkg=$pkg appBackupDir=${appBackupDir.absolutePath} exists=$dirExists") LogUtil.i(TAG, "restoreApps: pkg=$pkg appBackupDir=${appBackupDir.absolutePath} exists=$dirExists")
if (!dirExists) { if (!dirExists) {
failAtomic.incrementAndGet() failAtomic.incrementAndGet()
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "备份目录不存在")) emit(RestoreProgress(index + 1, packages.size, pkg, "done", "备份目录不存在"))
return@withPermit 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 private suspend fun installApk(
val successCount = successAtomic.get() packageName: String,
val failCount = failAtomic.get() appDir: File,
LogUtil.i(TAG, "restoreApps: completed — success=$successCount fail=$failCount elapsed=${elapsed}ms") cacheDir: File,
RestoreResult(successCount, failCount, elapsed) ): Boolean {
}
private suspend fun installApk(packageName: String, appDir: File, cacheDir: File): Boolean {
val apkNames = BackupOperation.listBackupFiles(appDir) val apkNames = BackupOperation.listBackupFiles(appDir)
LogUtil.i(TAG, "installApk: $packageName listBackupFiles returned ${apkNames?.size} files: $apkNames") LogUtil.i(TAG, "installApk: $packageName listBackupFiles returned ${apkNames?.size} files: $apkNames")
if (apkNames == null) { if (apkNames == null) {
@@ -176,11 +193,14 @@ object RestoreOperation {
for (name in apkFiltered) { for (name in apkFiltered) {
val src = File(appDir, name) val src = File(appDir, name)
val dst = File(installDir, 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) { if (copyResult.isSuccess && BackupOperation.backupPathExists(dst) && BackupOperation.backupFileSize(dst) > 0L) {
localApks.add(dst) localApks.add(dst)
} else { } 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() } val apkPaths = localApks.joinToString(" ") { it.absolutePath.shellEscape() }
if (localApks.size > 1) { if (localApks.size > 1) {
val result = RootShell.exec("pm install-create -r -t 2>/dev/null") val result = RootShell.exec("pm install-create -r -t 2>/dev/null")
val sessionId = result.output.lines() val sessionId =
.firstOrNull { it.contains("Success") } result.output
?.substringAfter("[") .lines()
?.substringBefore("]") .firstOrNull { it.contains("Success") }
?.substringAfter("[")
?.substringBefore("]")
if (sessionId != null) { if (sessionId != null) {
for ((i, apk) in localApks.withIndex()) { 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()}'") RootShell.exec("pm install-write '${sessionId.shellEscape()}' '$sessionName' '${apk.absolutePath.shellEscape()}'")
} }
val commit = RootShell.exec("pm install-commit '${sessionId.shellEscape()}'") val commit = RootShell.exec("pm install-commit '${sessionId.shellEscape()}'")
@@ -254,10 +276,21 @@ object RestoreOperation {
return false return false
} }
private suspend fun restoreData(packageName: String, userId: String, appDir: File, tarCmd: String, zstdCmd: String): Boolean { private suspend fun restoreData(
val fileNames = BackupOperation.listBackupFiles(appDir) packageName: String,
?.filter { it.contains("_data.tar") } userId: String,
?: run { Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}"); return false } 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()) { if (fileNames.isEmpty()) {
Log.w(TAG, "restoreData: no _data.tar in ${appDir.name}") Log.w(TAG, "restoreData: no _data.tar in ${appDir.name}")
return true return true
@@ -268,11 +301,13 @@ object RestoreOperation {
var anyExtracted = false var anyExtracted = false
val dataPaths = listOf("/data/data/$packageName", "/data/user_de/$userId/$packageName") val dataPaths = listOf("/data/data/$packageName", "/data/user_de/$userId/$packageName")
val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup") val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup")
val excludeArgs = dataPaths.flatMap { dataPath -> val excludeArgs =
excludeFolders.flatMap { folder -> dataPaths
listOf("--exclude='${dataPath.shellEscape()}/$folder'", "--exclude='${dataPath.shellEscape()}/$folder/*'") .flatMap { dataPath ->
} excludeFolders.flatMap { folder ->
}.joinToString(" ") listOf("--exclude='${dataPath.shellEscape()}/$folder'", "--exclude='${dataPath.shellEscape()}/$folder/*'")
}
}.joinToString(" ")
for (archive in dataFiles) { for (archive in dataFiles) {
val archivePath = archive.absolutePath.shellEscape() val archivePath = archive.absolutePath.shellEscape()
@@ -283,15 +318,25 @@ object RestoreOperation {
} }
// Build the extract command with exclusion flags // Build the extract command with exclusion flags
val baseCmd = when { val baseCmd =
archive.name.endsWith(".zst") -> when {
"set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null" archive.name.endsWith(".zst") -> {
archive.name.endsWith(".gz") -> "set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null"
"$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null" }
archive.name.endsWith(".tar") ->
"$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null" archive.name.endsWith(".gz") -> {
else -> { Log.w(TAG, "restoreData: unknown archive type ${archive.name}"); continue } "$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) val result = RootShell.exec(baseCmd)
if (result.isSuccess) { if (result.isSuccess) {
@@ -306,12 +351,13 @@ object RestoreOperation {
for (dataPath in dataPaths) { for (dataPath in dataPaths) {
// Try to get the existing context (if the path already existed) // Try to get the existing context (if the path already existed)
val existingContext = SELinuxUtil.getContext(dataPath) val existingContext = SELinuxUtil.getContext(dataPath)
val context = existingContext ?: run { val context =
// Path might not exist yet — use parent context with app_data_file substitution existingContext ?: run {
val parentDir = dataPath.substringBeforeLast("/") // Path might not exist yet — use parent context with app_data_file substitution
val parentContext = SELinuxUtil.getContext(parentDir) val parentDir = dataPath.substringBeforeLast("/")
parentContext?.replace("system_data_file", "app_data_file") val parentContext = SELinuxUtil.getContext(parentDir)
} parentContext?.replace("system_data_file", "app_data_file")
}
if (context != null) { if (context != null) {
Log.d(TAG, "restoreData: restoring SELinux context on $dataPath: $context") Log.d(TAG, "restoreData: restoring SELinux context on $dataPath: $context")
@@ -329,12 +375,16 @@ object RestoreOperation {
* or symbolic links pointing outside the tree. * or symbolic links pointing outside the tree.
* Accepts both absolute and relative paths — tar implementations vary. * Accepts both absolute and relative paths — tar implementations vary.
*/ */
private suspend fun isArchiveSafe(archive: File, zstdCmd: String = "zstd"): Boolean { private suspend fun isArchiveSafe(
val listCmd = if (archive.name.endsWith(".zst")) { archive: File,
"set -o pipefail; $zstdCmd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null" zstdCmd: String = "zstd",
} else { ): Boolean {
"tar tf '${archive.absolutePath.shellEscape()}' 2>/dev/null" 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) var result = RootShell.exec(listCmd)
// Fallback: try without pipefail (some Android shells don't support it) // Fallback: try without pipefail (some Android shells don't support it)
if (!result.isSuccess && archive.name.endsWith(".zst")) { 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 { private suspend fun restoreObb(
val obbNames = BackupOperation.listBackupFiles(appDir) packageName: String,
?.filter { it.contains("_obb.tar") } appDir: File,
?: return true tarCmd: String,
zstdCmd: String,
): Boolean {
val obbNames =
BackupOperation
.listBackupFiles(appDir)
?.filter { it.contains("_obb.tar") }
?: return true
if (obbNames.isEmpty()) return true if (obbNames.isEmpty()) return true
val obbFiles = obbNames.map { File(appDir, it) } val obbFiles = obbNames.map { File(appDir, it) }
// Build exclusion patterns for OBB cache/temp directories // Build exclusion patterns for OBB cache/temp directories
val obbPath = "/storage/emulated/0/Android/obb/$packageName" val obbPath = "/storage/emulated/0/Android/obb/$packageName"
val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup", "Backup_*") 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 var anyExtracted = false
for (archive in obbFiles) { for (archive in obbFiles) {
if (!isArchiveSafe(archive, zstdCmd)) continue if (!isArchiveSafe(archive, zstdCmd)) continue
val archivePath = archive.absolutePath.shellEscape() val archivePath = archive.absolutePath.shellEscape()
val result = when { val result =
archive.name.endsWith(".zst") -> { when {
RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null") 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) { if (result.isSuccess) {
Log.i(TAG, "restoreObb: extracted ${archive.name}") Log.i(TAG, "restoreObb: extracted ${archive.name}")
anyExtracted = true anyExtracted = true
@@ -397,12 +464,90 @@ object RestoreOperation {
val gidResult = RootShell.exec("stat -c %g '${obbPath.shellEscape()}' 2>/dev/null") val gidResult = RootShell.exec("stat -c %g '${obbPath.shellEscape()}' 2>/dev/null")
val gid = gidResult.output.trim().toIntOrNull() ?: 1023 // fallback to media_rw gid val gid = gidResult.output.trim().toIntOrNull() ?: 1023 // fallback to media_rw gid
RootShell.exec("chown -R $gid:$gid '${obbPath.shellEscape()}/' 2>/dev/null") 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") Log.i(TAG, "restoreObb: set ownership to $gid:$gid on $obbPath")
return anyExtracted 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 // Reject package names with special characters — they cannot be valid
// Android package names and would be unsafe in sed expressions below. // 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._-]*)+$"))) { 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 // Resolve the app's UID
val uidResult = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep 'userId=' | head -1") val uidResult = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep 'userId=' | head -1")
val uid = uidResult.output val uid =
.substringAfter("userId=", "") uidResult.output
.substringBefore(" ") .substringAfter("userId=", "")
.substringBefore(",") .substringBefore(" ")
.trim() .substringBefore(",")
.toIntOrNull() .trim()
.toIntOrNull()
if (uid == null) { if (uid == null) {
Log.w(TAG, "restoreSsaid: could not resolve UID for $packageName") 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) // Try XML-based approach first (more reliable across Android versions)
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml" val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
val xmlSuccess = run { val xmlSuccess =
// Check if file exists run {
val checkResult = RootShell.exec("test -f '$targetFile' && echo 'exists'") // Check if file exists
if (!checkResult.output.contains("exists")) { val checkResult = RootShell.exec("test -f '$targetFile' && echo 'exists'")
Log.d(TAG, "restoreSsaid: $targetFile does not exist, will use settings command") if (!checkResult.output.contains("exists")) {
return@run false Log.d(TAG, "restoreSsaid: $targetFile does not exist, will use settings command")
} return@run false
}
// Generate a UUID for the new entry // Generate a UUID for the new entry
val uuidResult = RootShell.exec("cat /proc/sys/kernel/random/uuid 2>/dev/null") val uuidResult = RootShell.exec("cat /proc/sys/kernel/random/uuid 2>/dev/null")
val id = uuidResult.output.trim() val id = uuidResult.output.trim()
// Strict UUID format check (also keeps the value safe inside the sed string) // Strict UUID format check (also keeps the value safe inside the sed string)
if (!id.matches(Regex("^[0-9a-fA-F-]{36}$"))) { if (!id.matches(Regex("^[0-9a-fA-F-]{36}$"))) {
Log.w(TAG, "restoreSsaid: could not generate UUID (got '$id'), falling back") Log.w(TAG, "restoreSsaid: could not generate UUID (got '$id'), falling back")
return@run false return@run false
} }
// Remove existing entry for this package and insert new one before </settings> // Remove existing entry for this package and insert new one before </settings>
val manipCmd = buildString { val manipCmd =
append("sed -i \"/package.*${packageName.shellEscape()}/d\" '$targetFile' && ") buildString {
append("sed -i \"s#</settings>#<setting id=\\\"$id\\\" package=\\\"${packageName.shellEscape()}\\\" value=\\\"${ssaidValue.shellEscape()}\\\" defaultValue=\\\"default\\\" />\\n</settings>#\" '$targetFile'") append("sed -i \"/package.*${packageName.shellEscape()}/d\" '$targetFile' && ")
} append(
val result = RootShell.exec(manipCmd) "sed -i \"s#</settings>#<setting id=\\\"$id\\\" package=\\\"${packageName.shellEscape()}\\\" value=\\\"${ssaidValue.shellEscape()}\\\" defaultValue=\\\"default\\\" />\\n</settings>#\" '$targetFile'",
if (!result.isSuccess) { )
Log.w(TAG, "restoreSsaid: XML edit failed: ${result.error}") }
return@run false 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 // 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 verifyCmd = RootShell.exec("grep -c \"${packageName.shellEscape()}\" '$targetFile' 2>/dev/null")
val entryCount = verifyCmd.output.trim().toIntOrNull() ?: 0 val entryCount = verifyCmd.output.trim().toIntOrNull() ?: 0
if (entryCount > 0) { if (entryCount > 0) {
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via XML (uid=$uid)") Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via XML (uid=$uid)")
true true
} else { } else {
Log.w(TAG, "restoreSsaid: XML edit completed but entry not found, falling back") Log.w(TAG, "restoreSsaid: XML edit completed but entry not found, falling back")
false false
}
} }
}
// Fallback: use settings put secure if XML approach failed // Fallback: use settings put secure if XML approach failed
if (!xmlSuccess) { 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 permFile = File(appDir, "permissions.txt")
val content = BackupOperation.readTextFile(permFile) ?: return val content = BackupOperation.readTextFile(permFile) ?: return
val parsedPerms = content.lines().mapNotNull { line -> val parsedPerms =
val name = line.substringBefore(":").trim().takeIf { it.isNotEmpty() && it.contains(".") } ?: return@mapNotNull null content.lines().mapNotNull { line ->
val granted = line.contains("granted=true") val name = line.substringBefore(":").trim().takeIf { it.isNotEmpty() && it.contains(".") } ?: return@mapNotNull null
Pair(name, granted) val granted = line.contains("granted=true")
} Pair(name, granted)
}
if (parsedPerms.isEmpty()) return if (parsedPerms.isEmpty()) return
@@ -532,34 +686,40 @@ object RestoreOperation {
private suspend fun resolveAppUid(packageName: String): Int? { private suspend fun resolveAppUid(packageName: String): Int? {
val pkgEsc = packageName.shellEscape() val pkgEsc = packageName.shellEscape()
// Method 1: pm list packages -U (reliable, consistent output format) // Method 1: pm list packages -U (reliable, consistent output format)
val pmResult = RootShell.exec("pm list packages -U 2>/dev/null | grep '${pkgEsc}$'") val pmResult = RootShell.exec("pm list packages -U 2>/dev/null | grep '$pkgEsc$'")
val pmUid = pmResult.output val pmUid =
.substringAfter(" uid:") pmResult.output
.trim() .substringAfter(" uid:")
.toIntOrNull() .trim()
.toIntOrNull()
if (pmUid != null) return pmUid if (pmUid != null) return pmUid
// Method 2: dumpsys package (fallback for older Android) // Method 2: dumpsys package (fallback for older Android)
val dsResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1") val dsResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1")
val dsUid = dsResult.output val dsUid =
.substringAfter("userId=", "") dsResult.output
.substringBefore(" ") .substringAfter("userId=", "")
.substringBefore(",") .substringBefore(" ")
.trim() .substringBefore(",")
.toIntOrNull() .trim()
.toIntOrNull()
if (dsUid != null) return dsUid if (dsUid != null) return dsUid
// Method 3: dumpsys with userId: separator (AOSP variant) // Method 3: dumpsys with userId: separator (AOSP variant)
val ds2Result = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId:' | head -1") val ds2Result = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId:' | head -1")
val ds2Uid = ds2Result.output val ds2Uid =
.substringAfter("userId:", "") ds2Result.output
.substringBefore(" ") .substringAfter("userId:", "")
.trim() .substringBefore(" ")
.toIntOrNull() .trim()
.toIntOrNull()
return ds2Uid return ds2Uid
} }
private suspend fun fixDataOwnership(packageName: String, userId: String) { private suspend fun fixDataOwnership(
packageName: String,
userId: String,
) {
val pkgEsc = packageName.shellEscape() val pkgEsc = packageName.shellEscape()
val uidEsc = userId.shellEscape() val uidEsc = userId.shellEscape()
@@ -569,22 +729,27 @@ object RestoreOperation {
return return
} }
// USER and USER_DE use uid:uid (app's own group) // USER, USER_DE, and external data paths
val dataPaths = listOf( val dataPaths =
"/data/data/$pkgEsc", listOf(
"/data/user_de/$uidEsc/$pkgEsc" "/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) { for (dataPath in dataPaths) {
RootShell.exec("chown -R $uid:$uid '$dataPath/' 2>/dev/null") RootShell.exec("chown -R $uid:$uid '$dataPath/' 2>/dev/null")
// Restore SELinux context instead of using restorecon (which applies defaults) // Restore SELinux context instead of using restorecon (which applies defaults)
val existingContext = SELinuxUtil.getContext(dataPath) val existingContext = SELinuxUtil.getContext(dataPath)
val context = existingContext ?: run { val context =
val parentDir = dataPath.substringBeforeLast("/") existingContext ?: run {
val parentContext = SELinuxUtil.getContext(parentDir) val parentDir = dataPath.substringBeforeLast("/")
parentContext?.replace("system_data_file", "app_data_file") val parentContext = SELinuxUtil.getContext(parentDir)
} parentContext?.replace("system_data_file", "app_data_file")
}
if (context != null) { if (context != null) {
SELinuxUtil.chcon(context, dataPath) SELinuxUtil.chcon(context, dataPath)
Log.d(TAG, "fixDataOwnership: restored SELinux context on $dataPath: $context") Log.d(TAG, "fixDataOwnership: restored SELinux context on $dataPath: $context")

View File

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