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:
10
.pi/wow.yaml
Normal file
10
.pi/wow.yaml
Normal 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
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **android-backup-gui** (1736 symbols, 4185 relationships, 151 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **android-backup-gui** (1734 symbols, 4049 relationships, 110 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **android-backup-gui** (1736 symbols, 4185 relationships, 151 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **android-backup-gui** (1734 symbols, 4049 relationships, 110 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import com.example.androidbackupgui.backup.ResticWrapper.SnapshotAppInfo
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import android.util.Log
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
@@ -23,7 +24,6 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
* Mirrors the logic from backup_script's modules/backup.sh.
|
||||
*/
|
||||
object BackupOperation {
|
||||
|
||||
private const val TAG = "BackupOperation"
|
||||
|
||||
@Serializable
|
||||
@@ -32,7 +32,7 @@ object BackupOperation {
|
||||
val total: Int,
|
||||
val packageName: String,
|
||||
val stage: String, // "apk", "data", "obb", "ssaid", "done"
|
||||
val message: String
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -41,7 +41,7 @@ object BackupOperation {
|
||||
val failCount: Int,
|
||||
val skippedCount: Int,
|
||||
val outputDir: String,
|
||||
val elapsedMs: Long
|
||||
val elapsedMs: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -65,11 +65,19 @@ object BackupOperation {
|
||||
noDataBackup: Set<String> = emptySet(),
|
||||
includePkgs: Set<String> = emptySet(),
|
||||
legacyApps: Map<String, SnapshotAppInfo>? = null,
|
||||
onProgress: suspend (BackupProgress) -> Unit = {}
|
||||
): BackupResult = withContext(Dispatchers.IO) {
|
||||
onProgress: suspend (BackupProgress) -> Unit = {},
|
||||
): BackupResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val emit: suspend (BackupProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
// Safety check: refuse to backup inside Android/data directories
|
||||
val absOut = outputDir.absolutePath
|
||||
if (absOut.contains("/Android/")) {
|
||||
LogUtil.e(TAG, "backupApps: refusing to backup inside Android/ directory: $absOut")
|
||||
return@withContext BackupResult(0, 0, 0, absOut, 0)
|
||||
}
|
||||
|
||||
// Create backup structure
|
||||
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
|
||||
if (!mkdirsForBackup(backupRoot)) {
|
||||
@@ -78,6 +86,19 @@ object BackupOperation {
|
||||
}
|
||||
LogUtil.i(TAG, "backupApps: starting backup of ${apps.size} apps to ${backupRoot.absolutePath}")
|
||||
|
||||
// Read previous metadata for incremental backup comparison
|
||||
val oldMetaFile = File(backupRoot, "app_details.json")
|
||||
val oldMetaJson =
|
||||
if (oldMetaFile.exists()) {
|
||||
try {
|
||||
JSONObject(readTextFile(oldMetaFile) ?: "{}")
|
||||
} catch (_: Exception) {
|
||||
JSONObject()
|
||||
}
|
||||
} else {
|
||||
JSONObject()
|
||||
}
|
||||
|
||||
// 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 })) {
|
||||
@@ -99,78 +120,184 @@ object BackupOperation {
|
||||
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 ->
|
||||
backupTargets
|
||||
.mapIndexed { index, app ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
ensureActive()
|
||||
val appDir = File(backupRoot, app.packageName.value)
|
||||
val pkgName = app.packageName.value
|
||||
val appDir = File(backupRoot, pkgName)
|
||||
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)
|
||||
// ── 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) "${app.packageName}_split_$i.apk" else "${app.packageName}.apk"
|
||||
RootShell.exec("cp '${apkPath.shellEscape()}' '${appDir.absolutePath.shellEscape()}/${destName.shellEscape()}'").isSuccess
|
||||
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 ${app.packageName}, continuing with data")
|
||||
if (!cpOk) LogUtil.w(TAG, "backupApps: APK cp failed for $pkgName, continuing")
|
||||
}
|
||||
} else {
|
||||
LogUtil.i(TAG, "backupApps: no APK paths for ${app.packageName} (not installed?), continuing")
|
||||
skippedAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "apk", "APK无变化,跳过"))
|
||||
}
|
||||
|
||||
// 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", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失"))
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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", "跳过数据备份(已排除)"))
|
||||
// ── 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, app.packageName.value, "data", "正在备份数据…"))
|
||||
if (!backupUserData(context, app.packageName.value, appDir, userId, config.compressionMethod)) {
|
||||
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, app.packageName.value, "done", "数据备份失败"))
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "数据备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
}
|
||||
} else if (skipData) {
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "数据无变化,跳过"))
|
||||
}
|
||||
|
||||
// 3. Backup OBB (if configured and exists)
|
||||
if (config.backupMode == 1 && config.backupObbData == 1) {
|
||||
val hasObb = AppScanner.hasObbData(app.packageName.value)
|
||||
// 3. Backup OBB
|
||||
if (config.backupMode == 1 && config.backupObbData == 1 && !skipData) {
|
||||
val hasObb = AppScanner.hasObbData(pkgName)
|
||||
if (hasObb) {
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "obb", "正在备份 OBB…"))
|
||||
if (!backupObb(app.packageName.value, appDir, config.compressionMethod)) {
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "obb", "正在备份 OBB…"))
|
||||
obbSize = backupObb(pkgName, appDir, config.compressionMethod)
|
||||
if (obbSize == null) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "OBB 备份失败"))
|
||||
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, app.packageName.value, "ssaid", "正在备份 SSAID…"))
|
||||
backupSsaid(app.packageName.value, appDir, userId)
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "ssaid", "正在备份 SSAID…"))
|
||||
backupSsaid(pkgName, 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")
|
||||
// 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
|
||||
}
|
||||
|
||||
// 5. Backup runtime permissions
|
||||
backupPermissions(app.packageName.value, appDir)
|
||||
perAppExtraMap[pkgName] =
|
||||
PerAppExtra(
|
||||
ssaid = ssaidValue,
|
||||
permissions = permissionsJson,
|
||||
keystore = hasKeystore,
|
||||
userSize = userSize,
|
||||
userDeSize = userDeSize,
|
||||
dataSize = dataSize,
|
||||
obbSize = obbSize,
|
||||
)
|
||||
|
||||
successAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "完成"))
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "完成"))
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
@@ -185,23 +312,30 @@ object BackupOperation {
|
||||
|
||||
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
|
||||
elapsedMs = elapsed,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Backup user data (/data/data + /data/user_de).
|
||||
* @return Pair(userSize, userDeSize) or null for the failing one.
|
||||
*/
|
||||
private suspend fun backupUserData(
|
||||
context: android.content.Context,
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
userId: String,
|
||||
compression: String
|
||||
): Boolean {
|
||||
compression: String,
|
||||
): Pair<Long?, Long?> {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
val outputFile = "${appDir.absolutePath.shellEscape()}/${pkgEsc}_data.tar"
|
||||
|
||||
@@ -254,10 +388,15 @@ object BackupOperation {
|
||||
if (!archiveCreated) {
|
||||
Log.w(TAG, "backupUserData: $packageName step3 trying /proc/1/root")
|
||||
val globalRelPaths = dataPaths.map { it.removePrefix("/") }
|
||||
val globalCmd = if (isZstd) {
|
||||
"cd /proc/1/root && set -o pipefail; $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
|
||||
val globalCmd =
|
||||
if (isZstd) {
|
||||
"cd /proc/1/root && set -o pipefail; $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(
|
||||
" ",
|
||||
) { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
|
||||
} else {
|
||||
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null"
|
||||
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(
|
||||
" ",
|
||||
) { "'${it.shellEscape()}'" }} 2>/dev/null"
|
||||
}
|
||||
result = RootShell.exec(globalCmd)
|
||||
archiveCreated = archiveHasData()
|
||||
@@ -266,31 +405,33 @@ object BackupOperation {
|
||||
|
||||
if (!archiveCreated) {
|
||||
LogUtil.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)")
|
||||
return false
|
||||
return null to null
|
||||
}
|
||||
|
||||
// Verify compression integrity
|
||||
val verifyOk = if (isZstd) {
|
||||
val verifyOk =
|
||||
if (isZstd) {
|
||||
RootShell.exec("$zstdCmd -t '$outputFile.zst' 2>/dev/null").isSuccess
|
||||
} else {
|
||||
RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess
|
||||
}
|
||||
if (!verifyOk) {
|
||||
Log.e(TAG, "backupUserData: $packageName integrity check FAILED")
|
||||
return false
|
||||
return null to null
|
||||
}
|
||||
|
||||
// Validate tar archive structure
|
||||
val tarValidateOk = if (isZstd) {
|
||||
val tarValidateOk =
|
||||
if (isZstd) {
|
||||
RootShell.exec("$zstdCmd -d -c '$outputFile.zst' 2>/dev/null | tar -tf - > /dev/null 2>&1").isSuccess
|
||||
} else {
|
||||
RootShell.exec("tar -tf '$outputFile.gz' > /dev/null 2>&1").isSuccess
|
||||
}
|
||||
if (!tarValidateOk) {
|
||||
Log.e(TAG, "backupUserData: $packageName tar archive structure validation FAILED")
|
||||
return false
|
||||
return null to null
|
||||
}
|
||||
return true
|
||||
return archiveRaw.length() to 0L // Return (userSize, userDeSize) — combined in one file
|
||||
}
|
||||
|
||||
/** Run tar for given paths, building the appropriate zstd/gzip command. */
|
||||
@@ -300,55 +441,157 @@ object BackupOperation {
|
||||
isZstd: Boolean,
|
||||
tarCmd: String = "tar",
|
||||
zstdCmd: String = "zstd",
|
||||
excludes: List<String> = emptyList()
|
||||
excludes: List<String> = emptyList(),
|
||||
): RootShell.ShellResult {
|
||||
val excludeArgs = if (excludes.isNotEmpty()) {
|
||||
val excludeArgs =
|
||||
if (excludes.isNotEmpty()) {
|
||||
excludes.joinToString(" ") { "--exclude='${it.shellEscape()}'" }
|
||||
} else ""
|
||||
} else {
|
||||
""
|
||||
}
|
||||
return if (isZstd) {
|
||||
RootShell.exec("set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'")
|
||||
RootShell.exec(
|
||||
"set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(
|
||||
" ",
|
||||
) { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'",
|
||||
)
|
||||
} else {
|
||||
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null")
|
||||
}
|
||||
}
|
||||
private suspend fun backupObb(packageName: String, appDir: File, compression: String): Boolean {
|
||||
|
||||
/**
|
||||
* Backup OBB data.
|
||||
* @return obbSize or null on failure.
|
||||
*/
|
||||
private suspend fun backupObb(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
compression: String,
|
||||
): Long? {
|
||||
val obbDir = "/storage/emulated/0/Android/obb/${packageName.shellEscape()}"
|
||||
val escapedAppDir = appDir.absolutePath.shellEscape()
|
||||
val escapedPkg = packageName.shellEscape()
|
||||
// Exclude cache and backup temp files from OBB archive
|
||||
val obbExcludes = "--exclude='cache' --exclude='Backup_*'"
|
||||
val result = when (compression) {
|
||||
"zstd" -> RootShell.exec("set -o pipefail; tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'")
|
||||
else -> RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
|
||||
val result =
|
||||
when (compression) {
|
||||
"zstd" -> {
|
||||
RootShell.exec(
|
||||
"set -o pipefail; tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
|
||||
}
|
||||
}
|
||||
if (!result.isSuccess) {
|
||||
Log.e(TAG, "Failed to backup OBB for $packageName: exit=${result.exitCode} err=${result.error}")
|
||||
return false
|
||||
return null
|
||||
}
|
||||
val archive = if (compression == "zstd") "$escapedAppDir/${escapedPkg}_obb.tar.zst" else "$escapedAppDir/${escapedPkg}_obb.tar.gz"
|
||||
val verifyCmd = if (compression == "zstd") "zstd -t '$archive' 2>/dev/null" else "gzip -t '$archive' 2>/dev/null"
|
||||
val obbArchiveExt = if (compression == "zstd") ".zst" else ".gz"
|
||||
val obbFile = File(appDir, "${packageName}_obb.tar$obbArchiveExt")
|
||||
val obbArchivePath = obbFile.absolutePath.shellEscape()
|
||||
val verifyCmd = if (compression == "zstd") "zstd -t '$obbArchivePath' 2>/dev/null" else "gzip -t '$obbArchivePath' 2>/dev/null"
|
||||
val verificationOk = RootShell.exec(verifyCmd).isSuccess
|
||||
if (!verificationOk) {
|
||||
Log.e(TAG, "OBB archive integrity check FAILED for $packageName")
|
||||
}
|
||||
// Validate OBB tar structure
|
||||
val tarListCmd = if (compression == "zstd") "zstd -d -c '$archive' 2>/dev/null | tar -tf - > /dev/null 2>&1" else "tar -tf '$archive' > /dev/null 2>&1"
|
||||
val tarListCmd =
|
||||
if (compression == "zstd") {
|
||||
"zstd -d -c '$obbArchivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1"
|
||||
} else {
|
||||
"tar -tf '$obbArchivePath' > /dev/null 2>&1"
|
||||
}
|
||||
val tarOk = RootShell.exec(tarListCmd).isSuccess
|
||||
if (!tarOk) {
|
||||
Log.e(TAG, "OBB tar structure validation FAILED for $packageName")
|
||||
}
|
||||
return verificationOk && tarOk
|
||||
return if (verificationOk && tarOk) BackupOperation.backupFileSize(obbFile) else null
|
||||
}
|
||||
|
||||
private suspend fun backupSsaid(packageName: String, appDir: File, userId: String) {
|
||||
/**
|
||||
* Backup external app data directory (/data/media/<userId>/Android/data/<pkg>).
|
||||
* This corresponds to /storage/emulated/0/Android/data/<pkg> in the user's profile.
|
||||
* @return dataSize or null if directory doesn't exist.
|
||||
*/
|
||||
private suspend fun backupExternalData(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
userId: String,
|
||||
compression: String,
|
||||
): Long? {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
val externalDataDir = "/data/media/$userId/Android/data/$pkgEsc"
|
||||
|
||||
// Check if the directory exists
|
||||
val checkResult = RootShell.exec("test -d '$externalDataDir' && echo 1 || echo 0")
|
||||
if (checkResult.output.trim() != "1") {
|
||||
Log.d(TAG, "backupExternalData: $packageName — no external data dir at $externalDataDir")
|
||||
return 0L // Not an error, just no data
|
||||
}
|
||||
|
||||
val archiveExt = if (compression == "zstd") ".zst" else ".gz"
|
||||
val archiveFile = File(appDir, "${packageName}_external_data.tar$archiveExt")
|
||||
val archivePath = archiveFile.absolutePath.shellEscape()
|
||||
val dataExcludes = "--exclude='cache' --exclude='Backup_*' --exclude='.ota'"
|
||||
|
||||
val result =
|
||||
if (compression == "zstd") {
|
||||
RootShell.exec(
|
||||
"set -o pipefail; tar -cf - $dataExcludes '$externalDataDir' 2>/dev/null | zstd -T0 -o '$archivePath'",
|
||||
)
|
||||
} else {
|
||||
RootShell.exec("tar -czf $dataExcludes '$archivePath' '$externalDataDir' 2>/dev/null")
|
||||
}
|
||||
|
||||
if (!result.isSuccess) {
|
||||
Log.w(TAG, "backupExternalData: $packageName tar failed: ${result.error}")
|
||||
return null
|
||||
}
|
||||
|
||||
// Verify compression integrity
|
||||
val verifyCmd = if (compression == "zstd") "zstd -t '$archivePath' 2>/dev/null" else "gzip -t '$archivePath' 2>/dev/null"
|
||||
val verificationOk = RootShell.exec(verifyCmd).isSuccess
|
||||
if (!verificationOk) {
|
||||
Log.e(TAG, "backupExternalData: $packageName integrity check FAILED")
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate tar structure
|
||||
val tarListCmd =
|
||||
if (compression == "zstd") {
|
||||
"zstd -d -c '$archivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1"
|
||||
} else {
|
||||
"tar -tf '$archivePath' > /dev/null 2>&1"
|
||||
}
|
||||
val tarOk = RootShell.exec(tarListCmd).isSuccess
|
||||
if (!tarOk) {
|
||||
Log.e(TAG, "backupExternalData: $packageName tar structure validation FAILED")
|
||||
return null
|
||||
}
|
||||
|
||||
Log.i(TAG, "backupExternalData: $packageName backed up (size=${archiveFile.length()})")
|
||||
return BackupOperation.backupFileSize(archiveFile)
|
||||
}
|
||||
|
||||
private suspend fun backupSsaid(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
userId: String,
|
||||
) {
|
||||
val ssaidFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
|
||||
// Parse XML value attribute for this package's SSAID entry
|
||||
val result = RootShell.exec("cat '$ssaidFile' 2>/dev/null")
|
||||
if (!result.isSuccess || result.output.isBlank()) return
|
||||
val ssaidLine = result.output.lines().firstOrNull { line ->
|
||||
val ssaidLine =
|
||||
result.output.lines().firstOrNull { line ->
|
||||
line.contains("packageName=\"$packageName\"") || line.contains("packageName='$packageName'")
|
||||
}
|
||||
val value = ssaidLine
|
||||
val value =
|
||||
ssaidLine
|
||||
?.substringAfter("value=\"")
|
||||
?.substringBefore("\"")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
@@ -362,7 +605,10 @@ object BackupOperation {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun backupPermissions(packageName: String, appDir: File) {
|
||||
private suspend fun backupPermissions(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
) {
|
||||
val result = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep -E 'granted=(true|false)'")
|
||||
if (result.output.isNotBlank()) {
|
||||
val permFile = File(appDir, "permissions.txt")
|
||||
@@ -374,24 +620,66 @@ object BackupOperation {
|
||||
|
||||
internal suspend fun buildAppDetailsJson(
|
||||
apps: List<AppInfo>,
|
||||
legacyApps: Map<String, SnapshotAppInfo>? = null
|
||||
legacyApps: Map<String, SnapshotAppInfo>? = null,
|
||||
perAppExtra: Map<String, PerAppExtra>? = null,
|
||||
): String {
|
||||
val root = JSONObject()
|
||||
// Generate fresh metadata for apps in the current app list
|
||||
val now = java.text.SimpleDateFormat("yyyy.MM.dd HH:mm:ss", java.util.Locale.US).format(java.util.Date())
|
||||
for (app in apps) {
|
||||
val entry = JSONObject()
|
||||
entry.put("label", app.label)
|
||||
entry.put("isSystem", app.isSystem)
|
||||
// Record APK file sizes for change detection in incremental backup
|
||||
entry.put("PackageName", app.packageName.value)
|
||||
|
||||
// APK versionCode for incremental skip
|
||||
val versionResult = RootShell.exec("dumpsys package '${app.packageName.value.shellEscape()}' | grep versionCode | head -1")
|
||||
val apkVersion =
|
||||
versionResult.output
|
||||
.substringAfter("versionCode=")
|
||||
.substringBefore(" ")
|
||||
.filter { it.isDigit() }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
if (apkVersion != null) entry.put("apk_version", apkVersion)
|
||||
|
||||
// APK file sizes
|
||||
val paths = AppScanner.getApkPaths(app.packageName.value)
|
||||
val sizes = paths.map { path ->
|
||||
val sizes =
|
||||
paths.map { path ->
|
||||
val result = RootShell.exec("stat -c%s '${path.shellEscape()}'")
|
||||
if (result.isSuccess) result.output.trim().toLongOrNull() ?: 0L else 0L
|
||||
}
|
||||
entry.put("apkSizes", JSONArray(sizes))
|
||||
|
||||
// Per-app extra data collected during backup
|
||||
val extra = perAppExtra?.get(app.packageName.value)
|
||||
if (extra != null) {
|
||||
if (extra.ssaid != null) entry.put("Ssaid", extra.ssaid)
|
||||
if (extra.permissions != null) entry.put("permissions", extra.permissions)
|
||||
if (extra.keystore) entry.put("keystore", "true")
|
||||
|
||||
fun putSize(
|
||||
key: String,
|
||||
value: Long?,
|
||||
) {
|
||||
if (value != null) {
|
||||
val obj = JSONObject()
|
||||
obj.put("Size", value.toString())
|
||||
entry.put(key, obj)
|
||||
}
|
||||
}
|
||||
putSize("user", extra.userSize)
|
||||
putSize("user_de", extra.userDeSize)
|
||||
putSize("data", extra.dataSize)
|
||||
putSize("obb", extra.obbSize)
|
||||
}
|
||||
|
||||
val timeObj = JSONObject()
|
||||
timeObj.put("date", now)
|
||||
entry.put("Backup time", timeObj)
|
||||
|
||||
root.put(app.packageName.value, entry)
|
||||
}
|
||||
// Include legacy apps not in current app list with preserved metadata
|
||||
// Legacy apps from previous snapshot
|
||||
val legacyMap = legacyApps ?: emptyMap()
|
||||
for ((pkg, legacy) in legacyMap) {
|
||||
if (!root.has(pkg)) {
|
||||
@@ -405,6 +693,19 @@ object BackupOperation {
|
||||
return root.toString(2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-app extra metadata collected during backup write phase.
|
||||
*/
|
||||
internal data class PerAppExtra(
|
||||
val ssaid: String? = null,
|
||||
val permissions: org.json.JSONObject? = null,
|
||||
val keystore: Boolean = false,
|
||||
val userSize: Long? = null,
|
||||
val userDeSize: Long? = null,
|
||||
val dataSize: Long? = null,
|
||||
val obbSize: Long? = null,
|
||||
)
|
||||
|
||||
/** Create backup output directory, falling back to root shell [mkdir -p]. */
|
||||
internal suspend fun mkdirsForBackup(dir: File): Boolean {
|
||||
if (dir.isDirectory) return true
|
||||
@@ -414,12 +715,17 @@ object BackupOperation {
|
||||
}
|
||||
|
||||
/** Write text to a file, falling back to root shell (base64 + cat). */
|
||||
internal suspend fun writeFileForBackup(file: File, text: String): Boolean {
|
||||
internal suspend fun writeFileForBackup(
|
||||
file: File,
|
||||
text: String,
|
||||
): Boolean {
|
||||
try {
|
||||
mkdirsForBackup(file.parentFile ?: return false)
|
||||
file.writeText(text)
|
||||
return true
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
} catch (_: Exception) {
|
||||
// fall through
|
||||
}
|
||||
try {
|
||||
mkdirsForBackup(file.parentFile ?: return false)
|
||||
val b64 = android.util.Base64.encodeToString(text.toByteArray(), android.util.Base64.NO_WRAP)
|
||||
@@ -435,15 +741,18 @@ object BackupOperation {
|
||||
internal suspend fun readTextFile(file: File): String? {
|
||||
try {
|
||||
if (file.exists()) return file.readText()
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
} catch (_: Exception) {
|
||||
// fall through
|
||||
}
|
||||
try {
|
||||
val result = RootShell.exec("cat '${file.absolutePath.shellEscape()}' 2>/dev/null")
|
||||
if (result.isSuccess && result.output.isNotBlank()) return result.output
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
} catch (_: Exception) {
|
||||
// fall through
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
/** Check if a path is a directory, falling back to root shell [test -d]. */
|
||||
internal suspend fun backupIsDirectory(dir: File): Boolean {
|
||||
if (dir.isDirectory()) return true
|
||||
@@ -477,11 +786,15 @@ object BackupOperation {
|
||||
val names = javaFiles.map { it.name }
|
||||
if (names.isNotEmpty()) return names
|
||||
}
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
} catch (_: Exception) {
|
||||
// fall through
|
||||
}
|
||||
try {
|
||||
val result = RootShell.exec("ls -1 '${dir.absolutePath.shellEscape()}' 2>/dev/null")
|
||||
if (!result.isSuccess || result.output.isBlank()) return null
|
||||
return result.output.lines().filter { it.isNotBlank() }
|
||||
} catch (_: Exception) { return null }
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.io.File
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
@@ -22,7 +22,6 @@ import kotlin.coroutines.coroutineContext
|
||||
* Only invoked when [BackupConfig.useStreaming] is enabled.
|
||||
*/
|
||||
object ResticStreamBackup {
|
||||
|
||||
private const val TAG = "ResticStreamBackup"
|
||||
private const val TAR_TIMEOUT_MS = 120_000L
|
||||
|
||||
@@ -47,8 +46,9 @@ object ResticStreamBackup {
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
onProgress: suspend (String) -> Unit = {}
|
||||
): AppResult<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
|
||||
onProgress: suspend (String) -> Unit = {},
|
||||
): AppResult<ResticWrapper.BackupSummary> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val emit: suspend (String) -> Unit = { msg -> withContext(Dispatchers.Main) { onProgress(msg) } }
|
||||
|
||||
cacheDir.mkdirs()
|
||||
@@ -59,7 +59,7 @@ object ResticStreamBackup {
|
||||
val mkfifoResult = RootShell.exec("mkfifo '${fifo.absolutePath.shellEscape()}'")
|
||||
if (!mkfifoResult.isSuccess) {
|
||||
LogUtil.e(TAG, "backup: mkfifo failed: ${mkfifoResult.error}")
|
||||
return@withContext err(AppError.Config("无法创建数据管道 (mkfifo)"))
|
||||
return@withContext err(AppError.LocalIO("无法创建数据管道 (mkfifo)", fifo.absolutePath))
|
||||
}
|
||||
Log.i(TAG, "FIFO created at ${fifo.absolutePath}")
|
||||
|
||||
@@ -69,18 +69,18 @@ object ResticStreamBackup {
|
||||
metaDir.mkdirs()
|
||||
BackupOperation.writeFileForBackup(
|
||||
File(metaDir, "appList.txt"),
|
||||
apps.joinToString("\n") { it.packageName.value }
|
||||
apps.joinToString("\n") { it.packageName.value },
|
||||
)
|
||||
BackupOperation.writeFileForBackup(
|
||||
File(metaDir, "app_details.json"),
|
||||
BackupOperation.buildAppDetailsJson(apps, legacyApps)
|
||||
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())
|
||||
if (!coroutineContext.isActive) return@withContext err(AppError.Cancelled)
|
||||
apkPaths.addAll(AppScanner.getApkPaths(app.packageName.value))
|
||||
}
|
||||
Log.i(TAG, "Collected ${apkPaths.size} APK paths")
|
||||
@@ -92,18 +92,25 @@ object ResticStreamBackup {
|
||||
|
||||
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) }
|
||||
for (tag in tags) {
|
||||
args.add("--tag")
|
||||
args.add(tag)
|
||||
}
|
||||
if (hostname != null) {
|
||||
args.add("--host")
|
||||
args.add(hostname)
|
||||
}
|
||||
|
||||
val cmdArgs = restic.runner.buildCommandArgs(args)
|
||||
val env = if (backend == "local") {
|
||||
val env =
|
||||
if (backend == "local") {
|
||||
restic.envResolver.buildLocalEnv(repoPath, password, restic.cacheDir)
|
||||
} else {
|
||||
// Remote backends: need bridge. Use blocking call inside withContext(IO).
|
||||
// For now, local only; remote bridge requires async setup not compatible
|
||||
// with the coroutineScope timing below. Remote will be added later.
|
||||
LogUtil.e(TAG, "backup: remote backend not yet supported for streaming")
|
||||
return@withContext err(AppError.Config("流式备份暂不支持远程后端,请使用本地仓库"))
|
||||
return@withContext err(AppError.Shell("流式备份暂不支持远程后端,请使用本地仓库", "backend_check", -1, ""))
|
||||
}
|
||||
|
||||
emit("流式备份开始 (${apps.size} 个应用)")
|
||||
@@ -115,7 +122,8 @@ object ResticStreamBackup {
|
||||
|
||||
coroutineScope {
|
||||
// Consumer: start restic, pipe stdin from FIFO, read progress
|
||||
val consumerJob = launch {
|
||||
val consumerJob =
|
||||
launch {
|
||||
try {
|
||||
Log.i(TAG, "Consumer: starting restic ${cmdArgs.joinToString(" ")}")
|
||||
val pb = ProcessBuilder(cmdArgs)
|
||||
@@ -124,7 +132,8 @@ object ResticStreamBackup {
|
||||
val process = pb.start()
|
||||
|
||||
// Daemon thread: pipe FIFO → process stdin
|
||||
val stdinThread = Thread {
|
||||
val stdinThread =
|
||||
Thread {
|
||||
try {
|
||||
java.io.FileInputStream(fifo).use { fis ->
|
||||
process.outputStream.use { pos ->
|
||||
@@ -134,18 +143,25 @@ object ResticStreamBackup {
|
||||
} catch (_: Exception) {
|
||||
// FIFO writer closed or process exited
|
||||
}
|
||||
}.apply { isDaemon = true; name = "restic-stdin-pipe" }
|
||||
}.apply {
|
||||
isDaemon = true
|
||||
name = "restic-stdin-pipe"
|
||||
}
|
||||
stdinThread.start()
|
||||
|
||||
// Drain stderr on a separate daemon thread to avoid pipe deadlock
|
||||
var stderrBytes = byteArrayOf()
|
||||
val stderrThread = Thread {
|
||||
val stderrThread =
|
||||
Thread {
|
||||
try {
|
||||
stderrBytes = process.errorStream.use { it.readAllBytesCompat() }
|
||||
} catch (_: Exception) {
|
||||
// stream closed early
|
||||
}
|
||||
}.apply { isDaemon = true; name = "restic-stderr-drain" }
|
||||
}.apply {
|
||||
isDaemon = true
|
||||
name = "restic-stderr-drain"
|
||||
}
|
||||
stderrThread.start()
|
||||
|
||||
// Read stdout line by line
|
||||
@@ -172,12 +188,21 @@ object ResticStreamBackup {
|
||||
line = reader.readLine()
|
||||
}
|
||||
} finally {
|
||||
try { reader.close() } catch (_: Exception) {}
|
||||
try {
|
||||
reader.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
val exitCode = process.waitFor()
|
||||
try { stdinThread.join(2_000) } catch (_: InterruptedException) {}
|
||||
try { stderrThread.join(1_000) } catch (_: InterruptedException) {}
|
||||
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}")
|
||||
@@ -185,11 +210,13 @@ object ResticStreamBackup {
|
||||
|
||||
if (exitCode == 0) {
|
||||
// Parse summary from stdout (last JSON line with message_type=summary)
|
||||
val summaryLine = stdoutLines.lastOrNull { line ->
|
||||
val summaryLine =
|
||||
stdoutLines.lastOrNull { line ->
|
||||
line.contains("\"message_type\"") && line.contains("\"summary\"")
|
||||
}
|
||||
if (summaryLine != null) {
|
||||
backupSummary = try {
|
||||
backupSummary =
|
||||
try {
|
||||
resticJson.decodeFromString<ResticWrapper.BackupSummary>(summaryLine)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Consumer: failed to parse summary: ${e.message}")
|
||||
@@ -203,7 +230,7 @@ object ResticStreamBackup {
|
||||
backupError = AppError.Restic("restic backup 失败", exitCode, stderrText)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// CoroutineScope cancellation propagates naturally
|
||||
throw e // 必须重新抛出,coroutineScope 才能传播取消
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(TAG, "Consumer: exception: ${e.message}")
|
||||
backupError = AppError.Restic("restic 进程异常: ${e.message}", -1, "")
|
||||
@@ -213,7 +240,8 @@ object ResticStreamBackup {
|
||||
}
|
||||
|
||||
// Producer: tar each app → FIFO
|
||||
val producerJob = launch {
|
||||
val producerJob =
|
||||
launch {
|
||||
try {
|
||||
// Small delay so consumer has time to start reading the FIFO
|
||||
delay(200)
|
||||
@@ -231,6 +259,11 @@ object ResticStreamBackup {
|
||||
|
||||
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"
|
||||
@@ -270,7 +303,7 @@ object ResticStreamBackup {
|
||||
|
||||
Log.i(TAG, "Producer: completed, $appIndex apps streamed")
|
||||
} catch (e: CancellationException) {
|
||||
// Normal cancellation
|
||||
throw e // 必须重新抛出,coroutineScope 才能传播取消
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(TAG, "Producer: exception: ${e.message}")
|
||||
}
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Performs restore of backed-up apps using root shell.
|
||||
* Mirrors the logic from backup_script's modules/restore.sh.
|
||||
*/
|
||||
object RestoreOperation {
|
||||
|
||||
private const val TAG = "RestoreOperation"
|
||||
|
||||
@Serializable
|
||||
@@ -29,14 +28,14 @@ object RestoreOperation {
|
||||
val total: Int,
|
||||
val packageName: String,
|
||||
val stage: String, // "install", "data", "obb", "ssaid", "permissions", "done"
|
||||
val message: String
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RestoreResult(
|
||||
val successCount: Int,
|
||||
val failCount: Int,
|
||||
val elapsedMs: Long
|
||||
val elapsedMs: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -48,8 +47,9 @@ object RestoreOperation {
|
||||
backupDir: File,
|
||||
userId: String = "0",
|
||||
filterPkgs: Set<String>? = null,
|
||||
onProgress: suspend (RestoreProgress) -> Unit = {}
|
||||
): RestoreResult = withContext(Dispatchers.IO) {
|
||||
onProgress: suspend (RestoreProgress) -> Unit = {},
|
||||
): RestoreResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val emit: suspend (RestoreProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
@@ -62,26 +62,31 @@ object RestoreOperation {
|
||||
val appListFile = File(backupDir, "appList.txt")
|
||||
val appListContent = BackupOperation.readTextFile(appListFile)
|
||||
LogUtil.i(TAG, "restoreApps: appListContent=${appListContent?.substringBefore("\n")?.take(100)}")
|
||||
val allPackages = appListContent?.let { content ->
|
||||
val allPackages =
|
||||
appListContent?.let { content ->
|
||||
content.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
} ?: run {
|
||||
LogUtil.i(TAG, "restoreApps: readTextFile returned null, trying listBackupFiles")
|
||||
val children = BackupOperation.listBackupFiles(backupDir)
|
||||
LogUtil.i(TAG, "restoreApps: listBackupFiles returned ${children?.size} children")
|
||||
children?.filter { name ->
|
||||
val apkFile = File(File(backupDir, name), "${name}.apk")
|
||||
val apkFile = File(File(backupDir, name), "$name.apk")
|
||||
val exists = BackupOperation.backupPathExists(apkFile)
|
||||
LogUtil.i(TAG, "restoreApps: child $name apkExists=$exists")
|
||||
exists
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
val packages = if (filterPkgs != null) {
|
||||
val packages =
|
||||
if (filterPkgs != null) {
|
||||
allPackages.filter { it in filterPkgs }
|
||||
} else {
|
||||
allPackages
|
||||
}
|
||||
LogUtil.i(TAG, "restoreApps: starting restore of ${packages.size} packages (all=${allPackages.size}) from ${backupDir.absolutePath}")
|
||||
LogUtil.i(
|
||||
TAG,
|
||||
"restoreApps: starting restore of ${packages.size} packages (all=${allPackages.size}) from ${backupDir.absolutePath}",
|
||||
)
|
||||
if (packages.isEmpty()) {
|
||||
LogUtil.w(TAG, "restoreApps: packages list is empty, nothing to restore")
|
||||
}
|
||||
@@ -134,6 +139,13 @@ object RestoreOperation {
|
||||
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)
|
||||
@@ -158,7 +170,12 @@ object RestoreOperation {
|
||||
LogUtil.i(TAG, "restoreApps: completed — success=$successCount fail=$failCount elapsed=${elapsed}ms")
|
||||
RestoreResult(successCount, failCount, elapsed)
|
||||
}
|
||||
private suspend fun installApk(packageName: String, appDir: File, cacheDir: File): Boolean {
|
||||
|
||||
private suspend fun installApk(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
cacheDir: File,
|
||||
): Boolean {
|
||||
val apkNames = BackupOperation.listBackupFiles(appDir)
|
||||
LogUtil.i(TAG, "installApk: $packageName listBackupFiles returned ${apkNames?.size} files: $apkNames")
|
||||
if (apkNames == null) {
|
||||
@@ -176,11 +193,14 @@ object RestoreOperation {
|
||||
for (name in apkFiltered) {
|
||||
val src = File(appDir, name)
|
||||
val dst = File(installDir, name)
|
||||
val copyResult = RootShell.exec("cp '${src.absolutePath.shellEscape()}' '${dst.absolutePath.shellEscape()}' && chmod 644 '${dst.absolutePath.shellEscape()}'")
|
||||
val copyResult =
|
||||
RootShell.exec(
|
||||
"cp '${src.absolutePath.shellEscape()}' '${dst.absolutePath.shellEscape()}' && chmod 644 '${dst.absolutePath.shellEscape()}'",
|
||||
)
|
||||
if (copyResult.isSuccess && BackupOperation.backupPathExists(dst) && BackupOperation.backupFileSize(dst) > 0L) {
|
||||
localApks.add(dst)
|
||||
} else {
|
||||
Log.w(TAG, "installApk: failed to copy APK ${name}, skipping")
|
||||
Log.w(TAG, "installApk: failed to copy APK $name, skipping")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,13 +208,15 @@ object RestoreOperation {
|
||||
val apkPaths = localApks.joinToString(" ") { it.absolutePath.shellEscape() }
|
||||
if (localApks.size > 1) {
|
||||
val result = RootShell.exec("pm install-create -r -t 2>/dev/null")
|
||||
val sessionId = result.output.lines()
|
||||
val sessionId =
|
||||
result.output
|
||||
.lines()
|
||||
.firstOrNull { it.contains("Success") }
|
||||
?.substringAfter("[")
|
||||
?.substringBefore("]")
|
||||
if (sessionId != null) {
|
||||
for ((i, apk) in localApks.withIndex()) {
|
||||
val sessionName = if (i == 0) "base.apk" else "split_${i}.apk"
|
||||
val sessionName = if (i == 0) "base.apk" else "split_$i.apk"
|
||||
RootShell.exec("pm install-write '${sessionId.shellEscape()}' '$sessionName' '${apk.absolutePath.shellEscape()}'")
|
||||
}
|
||||
val commit = RootShell.exec("pm install-commit '${sessionId.shellEscape()}'")
|
||||
@@ -254,10 +276,21 @@ object RestoreOperation {
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun restoreData(packageName: String, userId: String, appDir: File, tarCmd: String, zstdCmd: String): Boolean {
|
||||
val fileNames = BackupOperation.listBackupFiles(appDir)
|
||||
private suspend fun restoreData(
|
||||
packageName: String,
|
||||
userId: String,
|
||||
appDir: File,
|
||||
tarCmd: String,
|
||||
zstdCmd: String,
|
||||
): Boolean {
|
||||
val fileNames =
|
||||
BackupOperation
|
||||
.listBackupFiles(appDir)
|
||||
?.filter { it.contains("_data.tar") }
|
||||
?: run { Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}"); return false }
|
||||
?: run {
|
||||
Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}")
|
||||
return false
|
||||
}
|
||||
if (fileNames.isEmpty()) {
|
||||
Log.w(TAG, "restoreData: no _data.tar in ${appDir.name}")
|
||||
return true
|
||||
@@ -268,7 +301,9 @@ object RestoreOperation {
|
||||
var anyExtracted = false
|
||||
val dataPaths = listOf("/data/data/$packageName", "/data/user_de/$userId/$packageName")
|
||||
val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup")
|
||||
val excludeArgs = dataPaths.flatMap { dataPath ->
|
||||
val excludeArgs =
|
||||
dataPaths
|
||||
.flatMap { dataPath ->
|
||||
excludeFolders.flatMap { folder ->
|
||||
listOf("--exclude='${dataPath.shellEscape()}/$folder'", "--exclude='${dataPath.shellEscape()}/$folder/*'")
|
||||
}
|
||||
@@ -283,14 +318,24 @@ object RestoreOperation {
|
||||
}
|
||||
|
||||
// Build the extract command with exclusion flags
|
||||
val baseCmd = when {
|
||||
archive.name.endsWith(".zst") ->
|
||||
val baseCmd =
|
||||
when {
|
||||
archive.name.endsWith(".zst") -> {
|
||||
"set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null"
|
||||
archive.name.endsWith(".gz") ->
|
||||
}
|
||||
|
||||
archive.name.endsWith(".gz") -> {
|
||||
"$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null"
|
||||
archive.name.endsWith(".tar") ->
|
||||
}
|
||||
|
||||
archive.name.endsWith(".tar") -> {
|
||||
"$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null"
|
||||
else -> { Log.w(TAG, "restoreData: unknown archive type ${archive.name}"); continue }
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "restoreData: unknown archive type ${archive.name}")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
val result = RootShell.exec(baseCmd)
|
||||
@@ -306,7 +351,8 @@ object RestoreOperation {
|
||||
for (dataPath in dataPaths) {
|
||||
// Try to get the existing context (if the path already existed)
|
||||
val existingContext = SELinuxUtil.getContext(dataPath)
|
||||
val context = existingContext ?: run {
|
||||
val context =
|
||||
existingContext ?: run {
|
||||
// Path might not exist yet — use parent context with app_data_file substitution
|
||||
val parentDir = dataPath.substringBeforeLast("/")
|
||||
val parentContext = SELinuxUtil.getContext(parentDir)
|
||||
@@ -329,8 +375,12 @@ object RestoreOperation {
|
||||
* or symbolic links pointing outside the tree.
|
||||
* Accepts both absolute and relative paths — tar implementations vary.
|
||||
*/
|
||||
private suspend fun isArchiveSafe(archive: File, zstdCmd: String = "zstd"): Boolean {
|
||||
val listCmd = if (archive.name.endsWith(".zst")) {
|
||||
private suspend fun isArchiveSafe(
|
||||
archive: File,
|
||||
zstdCmd: String = "zstd",
|
||||
): Boolean {
|
||||
val listCmd =
|
||||
if (archive.name.endsWith(".zst")) {
|
||||
"set -o pipefail; $zstdCmd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null"
|
||||
} else {
|
||||
"tar tf '${archive.absolutePath.shellEscape()}' 2>/dev/null"
|
||||
@@ -357,8 +407,15 @@ object RestoreOperation {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreObb(packageName: String, appDir: File, tarCmd: String, zstdCmd: String): Boolean {
|
||||
val obbNames = BackupOperation.listBackupFiles(appDir)
|
||||
private suspend fun restoreObb(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
tarCmd: String,
|
||||
zstdCmd: String,
|
||||
): Boolean {
|
||||
val obbNames =
|
||||
BackupOperation
|
||||
.listBackupFiles(appDir)
|
||||
?.filter { it.contains("_obb.tar") }
|
||||
?: return true
|
||||
if (obbNames.isEmpty()) return true
|
||||
@@ -367,23 +424,33 @@ object RestoreOperation {
|
||||
// Build exclusion patterns for OBB cache/temp directories
|
||||
val obbPath = "/storage/emulated/0/Android/obb/$packageName"
|
||||
val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup", "Backup_*")
|
||||
val excludeArgs = excludeFolders.joinToString(" ") { "--exclude='${obbPath.shellEscape()}/$it' --exclude='${obbPath.shellEscape()}/$it/*'" }
|
||||
val excludeArgs =
|
||||
excludeFolders.joinToString(
|
||||
" ",
|
||||
) { "--exclude='${obbPath.shellEscape()}/$it' --exclude='${obbPath.shellEscape()}/$it/*'" }
|
||||
|
||||
var anyExtracted = false
|
||||
for (archive in obbFiles) {
|
||||
if (!isArchiveSafe(archive, zstdCmd)) continue
|
||||
val archivePath = archive.absolutePath.shellEscape()
|
||||
val result = when {
|
||||
val result =
|
||||
when {
|
||||
archive.name.endsWith(".zst") -> {
|
||||
RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
archive.name.endsWith(".gz") -> {
|
||||
RootShell.exec("$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
archive.name.endsWith(".tar") -> {
|
||||
RootShell.exec("$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
else -> { Log.w(TAG, "restoreObb: unknown archive type ${archive.name}"); continue }
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "restoreObb: unknown archive type ${archive.name}")
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (result.isSuccess) {
|
||||
Log.i(TAG, "restoreObb: extracted ${archive.name}")
|
||||
@@ -397,12 +464,90 @@ object RestoreOperation {
|
||||
val gidResult = RootShell.exec("stat -c %g '${obbPath.shellEscape()}' 2>/dev/null")
|
||||
val gid = gidResult.output.trim().toIntOrNull() ?: 1023 // fallback to media_rw gid
|
||||
RootShell.exec("chown -R $gid:$gid '${obbPath.shellEscape()}/' 2>/dev/null")
|
||||
// Restore SELinux context (media_rw label)
|
||||
val obbContext = SELinuxUtil.getContext(obbPath.substringBeforeLast("/"))
|
||||
if (obbContext != null) {
|
||||
SELinuxUtil.chcon(obbContext, obbPath)
|
||||
Log.i(TAG, "restoreObb: restored SELinux context on $obbPath")
|
||||
}
|
||||
|
||||
Log.i(TAG, "restoreObb: set ownership to $gid:$gid on $obbPath")
|
||||
|
||||
return anyExtracted
|
||||
}
|
||||
|
||||
private suspend fun restoreSsaid(packageName: String, appDir: File, userId: String) {
|
||||
/**
|
||||
* Restore external app data (/data/media/<userId>/Android/data/<pkg>).
|
||||
* Extracts _external_data.tar archive to the external data directory.
|
||||
*/
|
||||
private suspend fun restoreExternalData(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
tarCmd: String,
|
||||
zstdCmd: String,
|
||||
userId: String = "0",
|
||||
): Boolean {
|
||||
val extNames =
|
||||
BackupOperation
|
||||
.listBackupFiles(appDir)
|
||||
?.filter { it.contains("_external_data.tar") }
|
||||
?: return true
|
||||
if (extNames.isEmpty()) return true
|
||||
|
||||
var anyExtracted = false
|
||||
for (name in extNames) {
|
||||
val archive = File(appDir, name)
|
||||
if (!isArchiveSafe(archive, zstdCmd)) continue
|
||||
val archivePath = archive.absolutePath.shellEscape()
|
||||
val result =
|
||||
when {
|
||||
name.endsWith(".zst") -> {
|
||||
RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
name.endsWith(".gz") -> {
|
||||
RootShell.exec("$tarCmd -xzf '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
name.endsWith(".tar") -> {
|
||||
RootShell.exec("$tarCmd -xf '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "restoreExternalData: unknown archive type ${archive.name}")
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (result.isSuccess) {
|
||||
Log.i(TAG, "restoreExternalData: extracted ${archive.name}")
|
||||
anyExtracted = true
|
||||
} else {
|
||||
Log.e(TAG, "restoreExternalData: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}")
|
||||
}
|
||||
}
|
||||
|
||||
// Fix ownership: same as OBB (media_rw group)
|
||||
val extPath = "/data/media/$userId/Android/data/$packageName"
|
||||
val gidResult = RootShell.exec("stat -c %g '${extPath.shellEscape()}' 2>/dev/null")
|
||||
val gid = gidResult.output.trim().toIntOrNull() ?: 1023
|
||||
RootShell.exec("chown -R $gid:$gid '${extPath.shellEscape()}/' 2>/dev/null")
|
||||
// Restore SELinux context
|
||||
val extContext = SELinuxUtil.getContext(extPath.substringBeforeLast("/"))
|
||||
if (extContext != null) {
|
||||
SELinuxUtil.chcon(extContext, extPath)
|
||||
Log.i(TAG, "restoreExternalData: restored SELinux context on $extPath")
|
||||
}
|
||||
|
||||
Log.i(TAG, "restoreExternalData: set ownership to $gid:$gid on $extPath")
|
||||
|
||||
return anyExtracted
|
||||
}
|
||||
|
||||
private suspend fun restoreSsaid(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
userId: String,
|
||||
) {
|
||||
// Reject package names with special characters — they cannot be valid
|
||||
// Android package names and would be unsafe in sed expressions below.
|
||||
if (!packageName.matches(Regex("^[a-zA-Z][a-zA-Z0-9._-]*(\\.[a-zA-Z][a-zA-Z0-9._-]*)+$"))) {
|
||||
@@ -423,7 +568,8 @@ object RestoreOperation {
|
||||
|
||||
// Resolve the app's UID
|
||||
val uidResult = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep 'userId=' | head -1")
|
||||
val uid = uidResult.output
|
||||
val uid =
|
||||
uidResult.output
|
||||
.substringAfter("userId=", "")
|
||||
.substringBefore(" ")
|
||||
.substringBefore(",")
|
||||
@@ -437,7 +583,8 @@ object RestoreOperation {
|
||||
|
||||
// Try XML-based approach first (more reliable across Android versions)
|
||||
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
|
||||
val xmlSuccess = run {
|
||||
val xmlSuccess =
|
||||
run {
|
||||
// Check if file exists
|
||||
val checkResult = RootShell.exec("test -f '$targetFile' && echo 'exists'")
|
||||
if (!checkResult.output.contains("exists")) {
|
||||
@@ -455,9 +602,12 @@ object RestoreOperation {
|
||||
}
|
||||
|
||||
// Remove existing entry for this package and insert new one before </settings>
|
||||
val manipCmd = buildString {
|
||||
val manipCmd =
|
||||
buildString {
|
||||
append("sed -i \"/package.*${packageName.shellEscape()}/d\" '$targetFile' && ")
|
||||
append("sed -i \"s#</settings>#<setting id=\\\"$id\\\" package=\\\"${packageName.shellEscape()}\\\" value=\\\"${ssaidValue.shellEscape()}\\\" defaultValue=\\\"default\\\" />\\n</settings>#\" '$targetFile'")
|
||||
append(
|
||||
"sed -i \"s#</settings>#<setting id=\\\"$id\\\" package=\\\"${packageName.shellEscape()}\\\" value=\\\"${ssaidValue.shellEscape()}\\\" defaultValue=\\\"default\\\" />\\n</settings>#\" '$targetFile'",
|
||||
)
|
||||
}
|
||||
val result = RootShell.exec(manipCmd)
|
||||
if (!result.isSuccess) {
|
||||
@@ -488,10 +638,14 @@ object RestoreOperation {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restorePermissions(packageName: String, appDir: File) {
|
||||
private suspend fun restorePermissions(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
) {
|
||||
val permFile = File(appDir, "permissions.txt")
|
||||
val content = BackupOperation.readTextFile(permFile) ?: return
|
||||
val parsedPerms = content.lines().mapNotNull { line ->
|
||||
val parsedPerms =
|
||||
content.lines().mapNotNull { line ->
|
||||
val name = line.substringBefore(":").trim().takeIf { it.isNotEmpty() && it.contains(".") } ?: return@mapNotNull null
|
||||
val granted = line.contains("granted=true")
|
||||
Pair(name, granted)
|
||||
@@ -532,8 +686,9 @@ object RestoreOperation {
|
||||
private suspend fun resolveAppUid(packageName: String): Int? {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
// Method 1: pm list packages -U (reliable, consistent output format)
|
||||
val pmResult = RootShell.exec("pm list packages -U 2>/dev/null | grep '${pkgEsc}$'")
|
||||
val pmUid = pmResult.output
|
||||
val pmResult = RootShell.exec("pm list packages -U 2>/dev/null | grep '$pkgEsc$'")
|
||||
val pmUid =
|
||||
pmResult.output
|
||||
.substringAfter(" uid:")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
@@ -541,7 +696,8 @@ object RestoreOperation {
|
||||
|
||||
// Method 2: dumpsys package (fallback for older Android)
|
||||
val dsResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1")
|
||||
val dsUid = dsResult.output
|
||||
val dsUid =
|
||||
dsResult.output
|
||||
.substringAfter("userId=", "")
|
||||
.substringBefore(" ")
|
||||
.substringBefore(",")
|
||||
@@ -551,7 +707,8 @@ object RestoreOperation {
|
||||
|
||||
// Method 3: dumpsys with userId: separator (AOSP variant)
|
||||
val ds2Result = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId:' | head -1")
|
||||
val ds2Uid = ds2Result.output
|
||||
val ds2Uid =
|
||||
ds2Result.output
|
||||
.substringAfter("userId:", "")
|
||||
.substringBefore(" ")
|
||||
.trim()
|
||||
@@ -559,7 +716,10 @@ object RestoreOperation {
|
||||
return ds2Uid
|
||||
}
|
||||
|
||||
private suspend fun fixDataOwnership(packageName: String, userId: String) {
|
||||
private suspend fun fixDataOwnership(
|
||||
packageName: String,
|
||||
userId: String,
|
||||
) {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
val uidEsc = userId.shellEscape()
|
||||
|
||||
@@ -569,10 +729,14 @@ object RestoreOperation {
|
||||
return
|
||||
}
|
||||
|
||||
// USER and USER_DE use uid:uid (app's own group)
|
||||
val dataPaths = listOf(
|
||||
// USER, USER_DE, and external data paths
|
||||
val dataPaths =
|
||||
listOf(
|
||||
"/data/data/$pkgEsc",
|
||||
"/data/user_de/$uidEsc/$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) {
|
||||
@@ -580,7 +744,8 @@ object RestoreOperation {
|
||||
|
||||
// Restore SELinux context instead of using restorecon (which applies defaults)
|
||||
val existingContext = SELinuxUtil.getContext(dataPath)
|
||||
val context = existingContext ?: run {
|
||||
val context =
|
||||
existingContext ?: run {
|
||||
val parentDir = dataPath.substringBeforeLast("/")
|
||||
val parentContext = SELinuxUtil.getContext(parentDir)
|
||||
parentContext?.replace("system_data_file", "app_data_file")
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SortByAlpha
|
||||
@@ -17,10 +17,10 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.example.androidbackupgui.backup.*
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_START_BACKUP
|
||||
import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_STOP_BACKUP
|
||||
import com.example.androidbackupgui.backup.BackupService.Companion.EXTRA_STATUS_TEXT
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.ResticBinary
|
||||
import com.example.androidbackupgui.backup.WifiManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -56,7 +56,8 @@ fun BackupScreen() {
|
||||
// Re-apply sort/filter when dependencies change
|
||||
LaunchedEffect(allApps, sortMode, showSystemApps) {
|
||||
val filtered = if (showSystemApps) allApps else allApps.filter { !it.isSystem }
|
||||
val sorted = when (sortMode) {
|
||||
val sorted =
|
||||
when (sortMode) {
|
||||
SortMode.NAME_ASC -> filtered.sortedBy { it.label.lowercase(Locale.US) }
|
||||
SortMode.SIZE_DESC -> filtered.sortedByDescending { it.backupSize }
|
||||
}
|
||||
@@ -67,7 +68,6 @@ fun BackupScreen() {
|
||||
// ── Top controls card ──
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
||||
// Scan button
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
@@ -77,16 +77,38 @@ fun BackupScreen() {
|
||||
scope.launch {
|
||||
try {
|
||||
val userId = config.backupUserId
|
||||
val thirdParty = withContext(Dispatchers.IO) {
|
||||
val thirdParty =
|
||||
withContext(Dispatchers.IO) {
|
||||
AppScanner.scanThirdParty(context, userId = userId)
|
||||
}
|
||||
val system = withContext(Dispatchers.IO) {
|
||||
val system =
|
||||
withContext(Dispatchers.IO) {
|
||||
AppScanner.scanSystem(context, config, userId = userId)
|
||||
}
|
||||
val apps = if (showSystemApps) thirdParty + system else thirdParty
|
||||
allApps = apps
|
||||
selectedApps = apps.map { it.packageName.value }.toSet()
|
||||
val allPkgNames = apps.map { it.packageName.value }.toSet()
|
||||
selectedApps = allPkgNames
|
||||
|
||||
// Check for appList.txt with '!' prefix (no-data-backup markers)
|
||||
val appListFile = File(context.filesDir, "appList.txt")
|
||||
if (appListFile.exists()) {
|
||||
val content = appListFile.readText()
|
||||
val parsed = AppScanner.parseAppList(content)
|
||||
val excludeFromPrefix =
|
||||
parsed
|
||||
.filter { it.first in allPkgNames && !it.second }
|
||||
.map { it.first }
|
||||
.toSet()
|
||||
if (excludeFromPrefix.isNotEmpty()) {
|
||||
excludeDataFromBackup = excludeFromPrefix
|
||||
statusText = "共找到 ${apps.size} 个应用,${excludeFromPrefix.size} 个标记为仅APK"
|
||||
} else {
|
||||
statusText = "共找到 ${apps.size} 个应用,全部已选中"
|
||||
}
|
||||
} else {
|
||||
statusText = "共找到 ${apps.size} 个应用,全部已选中"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
statusText = "扫描应用失败: ${e.message}"
|
||||
} finally {
|
||||
@@ -95,7 +117,7 @@ fun BackupScreen() {
|
||||
}
|
||||
},
|
||||
enabled = !isScanning && !isRunning,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
if (isScanning) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||
@@ -115,7 +137,7 @@ fun BackupScreen() {
|
||||
label = { Text("A-Z") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.SortByAlpha, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
},
|
||||
)
|
||||
FilterChip(
|
||||
selected = sortMode == SortMode.SIZE_DESC,
|
||||
@@ -125,7 +147,7 @@ fun BackupScreen() {
|
||||
label = { Text("大小") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Storage, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TextButton(onClick = {
|
||||
@@ -147,14 +169,14 @@ fun BackupScreen() {
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
|
||||
// ── App list ──
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(sortedApps, key = { it.packageName.value }) { app ->
|
||||
AppListItem(
|
||||
@@ -162,13 +184,21 @@ fun BackupScreen() {
|
||||
isSelected = app.packageName.value in selectedApps,
|
||||
isDataExcluded = app.packageName.value in excludeDataFromBackup,
|
||||
onToggle = { checked ->
|
||||
selectedApps = if (checked) selectedApps + app.packageName.value
|
||||
else selectedApps - app.packageName.value
|
||||
selectedApps =
|
||||
if (checked) {
|
||||
selectedApps + app.packageName.value
|
||||
} else {
|
||||
selectedApps - app.packageName.value
|
||||
}
|
||||
},
|
||||
onExcludeDataToggle = { excluded ->
|
||||
excludeDataFromBackup = if (excluded) excludeDataFromBackup + app.packageName.value
|
||||
else excludeDataFromBackup - app.packageName.value
|
||||
excludeDataFromBackup =
|
||||
if (excluded) {
|
||||
excludeDataFromBackup + app.packageName.value
|
||||
} else {
|
||||
excludeDataFromBackup - app.packageName.value
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -176,7 +206,7 @@ fun BackupScreen() {
|
||||
// ── Bottom bar with backup button ──
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tonalElevation = 3.dp
|
||||
tonalElevation = 3.dp,
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
@@ -188,19 +218,25 @@ fun BackupScreen() {
|
||||
scope.launch {
|
||||
try {
|
||||
// 1. Start foreground service
|
||||
val serviceIntent = Intent(context, BackupService::class.java).apply {
|
||||
val serviceIntent =
|
||||
Intent(context, BackupService::class.java).apply {
|
||||
action = ACTION_START_BACKUP
|
||||
putExtra(EXTRA_STATUS_TEXT, "正在备份 ${toBackup.size} 个应用…")
|
||||
}
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, serviceIntent)
|
||||
} catch (_: Exception) {}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
// 2. Execute backup
|
||||
val outputDir = File(config.outputPath.ifEmpty {
|
||||
val outputDir =
|
||||
File(
|
||||
config.outputPath.ifEmpty {
|
||||
context.filesDir.absolutePath
|
||||
})
|
||||
val backupResult = withContext(Dispatchers.IO) {
|
||||
},
|
||||
)
|
||||
val backupResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
BackupOperation.backupApps(
|
||||
context = context,
|
||||
apps = toBackup,
|
||||
@@ -209,11 +245,13 @@ fun BackupScreen() {
|
||||
userId = config.backupUserId.toString(),
|
||||
noDataBackup = excludeDataFromBackup,
|
||||
onProgress = { progress ->
|
||||
statusText = "[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
|
||||
}
|
||||
statusText =
|
||||
"[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
|
||||
},
|
||||
)
|
||||
}
|
||||
statusText = "备份完成!成功: ${backupResult.successCount} 失败: ${backupResult.failCount} 耗时: ${backupResult.elapsedMs / 1000}s"
|
||||
statusText =
|
||||
"备份完成!成功: ${backupResult.successCount} 失败: ${backupResult.failCount} 耗时: ${backupResult.elapsedMs / 1000}s"
|
||||
|
||||
// 3. WiFi 备份
|
||||
WifiManager.backup(File(backupResult.outputDir))
|
||||
@@ -229,7 +267,8 @@ fun BackupScreen() {
|
||||
if (config.useStreaming == 1) {
|
||||
// ── Streaming path ──
|
||||
statusText = "正在流式备份到 restic 去重仓库…"
|
||||
val resticResult = withContext(Dispatchers.IO) {
|
||||
val resticResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
ResticWrapper.backupStreaming(
|
||||
apps = toBackup,
|
||||
noDataBackup = excludeDataFromBackup,
|
||||
@@ -244,13 +283,14 @@ fun BackupScreen() {
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
onProgress = { msg -> statusText = msg }
|
||||
onProgress = { msg -> statusText = msg },
|
||||
)
|
||||
}
|
||||
when (resticResult) {
|
||||
is AppResult.Success -> {
|
||||
val summary = resticResult.getOrNull()
|
||||
statusText = buildString {
|
||||
statusText =
|
||||
buildString {
|
||||
appendLine("流式备份完成!")
|
||||
appendLine("Restic ID: ${summary?.snapshotId?.take(8)}…")
|
||||
if (summary != null) {
|
||||
@@ -259,6 +299,7 @@ fun BackupScreen() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
statusText = "流式备份失败: ${resticResult.errorOrNull()?.message}"
|
||||
}
|
||||
@@ -266,7 +307,8 @@ fun BackupScreen() {
|
||||
} else {
|
||||
// ── Standard path (staging dir) ──
|
||||
statusText = "正在写入 restic 去重仓库…"
|
||||
val resticResult = withContext(Dispatchers.IO) {
|
||||
val resticResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
ResticWrapper.backup(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
@@ -280,19 +322,21 @@ fun BackupScreen() {
|
||||
backendShare = config.resticBackendShare,
|
||||
onProgress = { progress ->
|
||||
if (progress.messageType == "status") {
|
||||
statusText = "去重仓库: %.0f%% (%d/%d 个文件)".format(
|
||||
statusText =
|
||||
"去重仓库: %.0f%% (%d/%d 个文件)".format(
|
||||
progress.percentDone * 100,
|
||||
progress.filesDone,
|
||||
progress.totalFiles
|
||||
progress.totalFiles,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
when (resticResult) {
|
||||
is AppResult.Success -> {
|
||||
val summary = resticResult.getOrNull()
|
||||
statusText = buildString {
|
||||
statusText =
|
||||
buildString {
|
||||
appendLine("备份完成!")
|
||||
appendLine("成功: ${backupResult.successCount} 失败: ${backupResult.failCount}")
|
||||
appendLine("耗时: ${backupResult.elapsedMs / 1000}秒")
|
||||
@@ -302,6 +346,7 @@ fun BackupScreen() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
statusText = "restic 快照失败: ${resticResult.errorOrNull()?.message}"
|
||||
}
|
||||
@@ -312,28 +357,40 @@ fun BackupScreen() {
|
||||
} 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) ->
|
||||
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) ->
|
||||
}
|
||||
|
||||
errMsg.contains(
|
||||
"EACCES",
|
||||
ignoreCase = true,
|
||||
) || errMsg.contains("Permission denied", ignoreCase = true) -> {
|
||||
"权限不足,请检查存储权限"
|
||||
else -> null
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
statusText = if (hint != null) "备份异常: ${e.message} ($hint)" else "备份异常: ${e.message}"
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
isRunning = false
|
||||
try {
|
||||
val stopIntent = Intent(context, BackupService::class.java).apply {
|
||||
val stopIntent =
|
||||
Intent(context, BackupService::class.java).apply {
|
||||
action = ACTION_STOP_BACKUP
|
||||
}
|
||||
context.startService(stopIntent)
|
||||
} catch (_: Exception) {}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isRunning && selectedApps.isNotEmpty(),
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp)
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
) {
|
||||
if (isRunning) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||
@@ -351,27 +408,27 @@ private fun AppListItem(
|
||||
isSelected: Boolean,
|
||||
isDataExcluded: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
onExcludeDataToggle: (Boolean) -> Unit
|
||||
onExcludeDataToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
Card(
|
||||
onClick = { onToggle(!isSelected) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(checked = isSelected, onCheckedChange = { onToggle(it) })
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = app.label.ifEmpty { app.packageName.value },
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = app.packageName.value,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (isSelected) {
|
||||
@@ -379,8 +436,12 @@ private fun AppListItem(
|
||||
Text(
|
||||
"数据",
|
||||
textDecoration = if (isDataExcluded) TextDecoration.LineThrough else TextDecoration.None,
|
||||
color = if (isDataExcluded) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.primary
|
||||
color =
|
||||
if (isDataExcluded) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user