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: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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user