30 Commits
v1.15 ... v1.16

Author SHA1 Message Date
sakuradairong
2d9ec54014 chore: bump version to 1.16 2026-06-09 22:31:14 +08:00
sakuradairong
8c6021170f fix: 备份恢复全链路修复与功能增强
- 修复备份自身应用时 force-stop 导致闪退(加入 context.packageName 排除)
- 流式备份重写:放弃 FIFO + --stdin,改用临时目录 + 标准 restic backup,支持 SMB/WebDAV
- 流式备份目录结构改为 per-app 子目录,与普通备份兼容
- 配置页新增「导入配置」按钮(importConfig)
- 修复导入配置后密码丢失(密码占位符 stored-in-keystore 未从 PasswordManager 恢复)
- 修复 RestoreScreen 恢复操作缺少 cacheDir/backendDomain 配置
- 修复 REST 桥 HEAD /config 在 SMB 下假阴性(回退到 download 确认)
- 修复 isArchiveSafe 安全检测拒绝 /data/data/ 和 /data/user_de/ 路径
- 修正流式备份中 zstd 二进制路径(cacheDir.parentFile 少一层 files/)
- loadResticSnapshot/loadResticAppDetails 兼容新旧流式备份目录结构
- 新增 BackupViewModel、BackendExecutor、PasswordManager 等文件
2026-06-09 22:22:45 +08:00
sakuradairong
a3355d07e4 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)
2026-06-09 15:41:50 +08:00
sakuradairong
528c1ac029 fix(streaming): stderr daemon 排空(fix deadlock) + userId 参数传递 + writeFileForBackup 回退 2026-06-08 17:15:59 +08:00
sakuradairong
22e5a8ab41 feat(streaming): Phase 4 — BackupScreen 流式/标准分流(useStreaming 开关控制) 2026-06-08 16:59:35 +08:00
sakuradairong
9020b868d0 feat(streaming): Phase 2+3 — FIFO 创建/producer tar 写入/consumer restic stdin/进度解析 2026-06-08 16:57:30 +08:00
sakuradairong
7b34b565a9 feat(streaming): Phase 1 — BackupConfig.useStreaming 开关 + ResticStreamBackup 骨架 + ResticWrapper 分流 2026-06-08 16:54:44 +08:00
sakuradairong
e72ab719ce fix: runResticStreaming daemon 线程并发排空 stderr,修复缓冲区满死锁 2026-06-08 16:44:23 +08:00
sakuradairong
0bb379c1a4 chore: 移除死代码 StreamingBackup.kt(零调用方,prepareStreaming/launchDataProducer) 2026-06-08 16:43:47 +08:00
sakuradairong
6fe4920a85 chore: 移除死代码 ResticCommandRunner.runResticWithStdin(零调用方) 2026-06-08 16:43:03 +08:00
sakuradairong
29f40434e8 chore: 移除死代码 ResticBackup.backupStdin(零调用方) 2026-06-08 16:42:08 +08:00
sakuradairong
f4b7dc3aec chore: 移除死代码 ResticWrapper.backupStdin(零调用方) 2026-06-08 16:40:53 +08:00
sakuradairong
00cf2bc2f4 fix: restoreObb 返回 Boolean,提取失败时 warn 不阻塞(OBB 可重新下载) 2026-06-08 16:27:26 +08:00
sakuradairong
e9a1697145 fix: restoreSsaid 入口处增加 packageName 正则格式校验,防 sed 注入 2026-06-08 16:26:37 +08:00
sakuradairong
fbf3f9d179 fix: installApk 验证 cp 复制成功且文件大小 > 0 再加入安装列表 2026-06-08 16:26:21 +08:00
sakuradairong
bd5f4b92ab fix: isArchiveSafe 增加符号链接目标检查,拒绝绝对路径和 .. 穿越 2026-06-08 16:25:18 +08:00
sakuradairong
b844eaba7f fix: installApk 重试前 4s poll 检测,避免 pm 延迟导致误卸载重装 2026-06-08 16:24:50 +08:00
sakuradairong
1213f9fe18 fix: restoreData 返回 Boolean,数据恢复失败时标记 fail 2026-06-08 16:24:09 +08:00
sakuradairong
28e49da9ed fix: backupUserData 使用 backupPathExists/backupFileSize 检查存档
archiveRaw.exists() 和 archiveRaw.length() 在 FUSE 上返回 false/0,
导致 archiveCreated 永远 false → backupUserData 返回 false → 误报失败。
改用 BackupOperation.backupPathExists (test -e) 和 backupFileSize (stat -c%s)
验证 root shell tar 实际写入的存档文件。

新增 backupFileSize 辅助函数。
2026-06-08 15:27:45 +08:00
sakuradairong
a15ca7243a fix: APK 备份失败不跳过用户数据
不再因 APK 无法复制 (app 未安装/cp 失败) 就 return@withPermit
跳过整条数据备份链路。继续备份 userdata/obb/ssaid/permissions,
仅用 LogUtil.w 记录 APK 失败日志,不再计入 fail 计数。
2026-06-08 15:24:47 +08:00
sakuradairong
23fdbab406 fix: installApk 复制 APK 到 cache 后再 pm install
pm 命令无法直接读取外部存储路径的 APK 文件(SELinux 限制),
安装前先将 APK cp 到 cacheDir(内部存储)再执行 pm install。
新增 cacheDir 参数从 restoreApps 传入。
2026-06-08 15:15:34 +08:00
sakuradairong
8122f64923 fix: listBackupFiles 跳过 Java 空数组回落 root shell
FUSE 文件系统可能将 EPERM 表现为空数组而非 null,
导致 listBackupFiles 提前返回 [] 从未执行 ls -1 回落。
改为仅当 Java 返回非空结果才提前返回,空数组继续走 root shell。
2026-06-08 15:11:17 +08:00
sakuradairong
b249942c13 fix: loadFromDir 过滤无备份数据的应用
loadFromDir 验证每个应用备份目录是否包含 .apk 文件,
跳过备份失败的空目录,UI 提示X个应用备份数据缺失已自动跳过。
防止用户选择无法恢复的应用。
2026-06-08 15:07:18 +08:00
sakuradairong
8ff28b14f6 chore: add diagnostic logging to restore flow
在 restoreApps/installApk 中加入关键步骤日志:
- readTextFile 是否成功读取 appList.txt
- listBackupFiles/backupPathExists 结果
- pm install 的 exitCode 和 output
帮助定位外部存储恢复失败原因
2026-06-08 15:00:40 +08:00
sakuradairong
250b387079 fix: 恢复页面读取外部存储路径支持
BackupOperation: 新增 readTextFile / backupPathExists / backupIsDirectory /
listBackupFiles 辅助函数,所有文件操作优先 Java API 后以 root shell 回落
(cat / test / ls),使外部存储路径的备份可被读取。
RestoreOperation: restoreApps / installApk / restoreData / restoreObb /
restoreSsaid / restorePermissions 全部改用 root shell 回落读取。
RestoreScreen: 新增选择目录按钮 SAF 文件选择器;loadFromDir /
readLocalAppDetails 改用 root shell 回落。
配置页 resolveSafTreeUri 提取为可复用顶层函数。
2026-06-08 14:49:34 +08:00
sakuradairong
246eff5f0b fix: 外部存储写文件回落改为 root shell + base64
/data/local/tmp/ 对非 root 进程不可写,旧回落策略失效。
改用 base64 + root shell 直接写入目标路径,完全绕过 Java File API 和 FUSE。
2026-06-08 14:39:50 +08:00
sakuradairong
64ded465e6 fix: 外部存储路径 EPERM 时通过 root shell 回落写入
新增 mkdirsForBackup / writeFileForBackup 辅助函数:
- 优先尝试 Java File API(内部存储直写)
- 失败后回退到 root shell mkdir -p / cp(绕过 FUSE UID 检查)
- 临时文件写入 /data/local/tmp 后用 root cp 拷贝到目标路径
- 替换 backupApps / backupSsaid / backupPermissions 中所有 writeText 调用
2026-06-08 14:37:07 +08:00
sakuradairong
1fdba019d7 fix: 日志页面闪退
- 移除 Composable 内 return@Column 导致 Compose slot 表错乱
- file.readLines() 切到 Dispatchers.IO 避免主线程 IO
- 使用 rememberCoroutineScope 替代泄漏的 MainScope
2026-06-08 14:32:38 +08:00
sakuradairong
1fb93c3137 feat: 新增日志查看与导出功能
底栏新增日志页面,可查看 LogUtil 日志文件列表、预览内容、
导出到任意位置、删除旧日志。
2026-06-08 14:28:43 +08:00
sakuradairong
2c52b198bd feat: 自定义输出目录支持 SAF 文件选择器
在配置页面的输出目录旁新增选择按钮,调用系统文件管理器
(OpenDocumentTree) 选取目录,将 SAF URI 自动转换为文件系统路径。
支持主存储 (primary: -> /storage/emulated/0/) 和外置 SD 卡。
2026-06-08 14:20:04 +08:00
34 changed files with 4628 additions and 2268 deletions

10
.pi/wow.yaml Normal file
View File

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

View File

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

View File

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

View File

@@ -24,8 +24,8 @@ android {
applicationId "com.example.androidbackupgui"
minSdk 24
targetSdk 34
versionCode 15
versionName "1.15"
versionCode 16
versionName "1.16"
}
buildFeatures {
compose = true
@@ -97,6 +97,7 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.0"
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'

View File

@@ -1,23 +1,23 @@
package com.example.androidbackupgui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.example.androidbackupgui.backup.LogUtil
import com.example.androidbackupgui.backup.MissingAlgoProvider
import com.example.androidbackupgui.backup.PasswordManager
import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.ResticWrapper
import com.example.androidbackupgui.backup.defaultResticWrapper
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.ui.AppScaffold
import com.example.androidbackupgui.ui.theme.AppTheme
import com.google.android.material.color.DynamicColors
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -27,16 +27,19 @@ class MainActivity : ComponentActivity() {
RootShell.configure()
// Initialize restic binary path
ResticBinary.prepare(this)?.let { ResticWrapper.binaryPath = it }
ResticBinary.prepare(this)?.let { defaultResticWrapper.binaryPath = it }
// Initialize file-based logging
// Initialize file-based logging and secure credential storage
LogUtil.init(filesDir)
PasswordManager.init(this)
// 启动时初始化 SMB 加密库MD4/AESCMAC避免首次 SMB 操作时延迟失败
MissingAlgoProvider.register()
setContent {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
color = MaterialTheme.colorScheme.background,
) {
AppScaffold()
}

View File

@@ -0,0 +1,136 @@
package com.example.androidbackupgui.backup
import java.io.File
/**
* 后端执行器——消除 [ResticBackup]、[ResticRestore]、[ResticSnapshotOps]、
* [ResticMaintenance] 和 [ResticRepoInit] 中重复的 local-vs-remote 分支。
*
* 使用方式(替换所有子模块中的 if backend == "local" 模式):
*
* ```
* executor.withBackend(
* repoPath = repoPath, password = password, cacheDir = cacheDir,
* backend = backend, backendUrl = backendUrl,
* backendUser = backendUser, backendPass = backendPass,
* backendShare = backendShare, backendDomain = backendDomain,
* runner = runner, envResolver = envResolver, bridgeRunner = bridgeRunner,
* ) { env ->
* val result = runner.runRestic(env, args)
* // parse result
* }
* ```
*/
class BackendExecutor {
/**
* 使用 [block] 执行 restic 操作。
*
* - "local" 后端:直接通过 [ResticEnvResolver.buildLocalEnv] 构建环境
* - 远程后端:通过 [RestBridgeRunner.withBridge] 启动 REST 桥后再构建环境
*
* @param T 返回值的类型(例如 [AppResult]
* @param block 接收环境变量 Map返回 [T]
*/
suspend fun <T> withBackend(
repoPath: String,
password: String,
cacheDir: String,
backend: String,
backendUrl: String,
backendUser: String,
backendPass: String,
backendShare: String,
backendDomain: String,
runner: ResticCommandRunner,
envResolver: ResticEnvResolver,
bridgeRunner: RestBridgeRunner,
block: suspend (Map<String, String>) -> T,
): T {
if (backend == "local") {
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
return block(env)
}
return bridgeRunner.withBridge(
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
backendDomain,
repoPath,
File(cacheDir),
) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
block(env)
}
}
/**
* 与 [withBackend] 相同,但自动将 [args] 传给 [runner.runRestic]。
*
* 适用于 "run-and-parse-exit-code" 模式的简化调用。
*/
suspend fun runResticWithBackend(
args: List<String>,
repoPath: String,
password: String,
cacheDir: String,
backend: String,
backendUrl: String,
backendUser: String,
backendPass: String,
backendShare: String,
backendDomain: String,
runner: ResticCommandRunner,
envResolver: ResticEnvResolver,
bridgeRunner: RestBridgeRunner,
): ResticCommandRunner.CommandResult =
withBackend(
repoPath = repoPath,
password = password,
cacheDir = cacheDir,
backend = backend,
backendUrl = backendUrl,
backendUser = backendUser,
backendPass = backendPass,
backendShare = backendShare,
backendDomain = backendDomain,
runner = runner,
envResolver = envResolver,
bridgeRunner = bridgeRunner,
) { env -> runner.runRestic(env, args) }
/**
* 与 [runResticWithBackend] 相同,但使用流式模式。
*/
suspend fun runResticStreamingWithBackend(
args: List<String>,
repoPath: String,
password: String,
cacheDir: String,
backend: String,
backendUrl: String,
backendUser: String,
backendPass: String,
backendShare: String,
backendDomain: String,
runner: ResticCommandRunner,
envResolver: ResticEnvResolver,
bridgeRunner: RestBridgeRunner,
onLine: suspend (String) -> Unit = {},
): ResticCommandRunner.CommandResult =
withBackend(
repoPath = repoPath,
password = password,
cacheDir = cacheDir,
backend = backend,
backendUrl = backendUrl,
backendUser = backendUser,
backendPass = backendPass,
backendShare = backendShare,
backendDomain = backendDomain,
runner = runner,
envResolver = envResolver,
bridgeRunner = bridgeRunner,
) { env -> runner.runResticStreaming(env, args, onLine) }
}

View File

@@ -1,7 +1,7 @@
package com.example.androidbackupgui.backup
import java.io.File
import kotlinx.serialization.Serializable
import java.io.File
/**
* Mirrors backup_settings.conf from backup_script.
@@ -12,68 +12,68 @@ import kotlinx.serialization.Serializable
@Serializable
data class BackupConfig(
// Operation mode
val lo: Int = 0, // 0=volume key, 1=volume force, 2=keyboard
val backgroundExecution: Int = 0, // 0=foreground, 1=background
val setDisplayPowerMode: Int = 0, // 1=keep screen on during backup
val shellLang: String = "", // ""=auto, "1"=zh-CN, "0"=zh-TW
val lo: Int = 0, // 0=volume key, 1=volume force, 2=keyboard
val backgroundExecution: Int = 0, // 0=foreground, 1=background
val setDisplayPowerMode: Int = 0, // 1=keep screen on during backup
val shellLang: String = "", // ""=auto, "1"=zh-CN, "0"=zh-TW
// Paths
val outputPath: String = "", // Custom output dir
val listLocation: String = "", // Custom appList.txt location
val outputPath: String = "", // Custom output dir
val listLocation: String = "", // Custom appList.txt location
// Update
val update: Int = 1, // 1=auto update
val cdn: Int = 1, // CDN node
val update: Int = 1, // 1=auto update
val cdn: Int = 1, // CDN node
// Filters
val mountPoint: String = "rannki|0000-1",
val user: String = "",
// Backup mode
val backupMode: Int = 1, // 1=data+apk, 0=apk only
val backupMode: Int = 1, // 1=data+apk, 0=apk only
val backupUserData: Int = 1,
val backupObbData: Int = 1,
val backupMedia: Int = 0,
val backgroundAppsIgnore: Int = 0,
val backupUserId: Int = 0, // Android user ID (0=Owner)
val backupUserId: Int = 0, // Android user ID (0=Owner)
// Custom paths
val customPath: List<String> = listOf(
"/storage/emulated/0/Pictures/",
"/storage/emulated/0/Download/",
"/storage/emulated/0/Music",
"/storage/emulated/0/DCIM/",
"/data/adb"
),
val customPath: List<String> =
listOf(
"/storage/emulated/0/Pictures/",
"/storage/emulated/0/Download/",
"/storage/emulated/0/Music",
"/storage/emulated/0/DCIM/",
"/data/adb",
),
// Blacklist
val blacklistMode: Int = 0, // 1=full ignore, 0=apk only
val blacklistMode: Int = 0, // 1=full ignore, 0=apk only
val blacklist: List<String> = emptyList(),
// Whitelists
val whitelist: List<String> = emptyList(),
val system: List<String> = emptyList(),
// Compression
val compressionMethod: String = "zstd", // zstd or tar
// Terminal colors
val rgbA: Int = 226,
val rgbB: Int = 123,
val rgbC: Int = 177,
val backupWifi: Int = 1,
// Restic deduplicated backup with rclone backend
val resticEnabled: Int = 0,
val resticRepo: String = "",
/**
* restic 密码不在配置文件中明文存储。始终通过 PasswordManager 存取。
* 此字段仅保留默认值,用于反序列化兼容旧版配置文件。
*/
@Deprecated("Use PasswordManager.getResticPassword() instead; kept only for config file backward compat")
val resticPassword: String = "",
val resticBackend: String = "local", // local / webdav / smb
val resticBackend: String = "local", // local / webdav / smb
val resticBackendUrl: String = "",
val resticBackendUser: String = "",
/** @deprecated Use PasswordManager instead */
@Deprecated("Use PasswordManager instead")
val resticBackendPass: String = "",
val resticBackendShare: String = "", // SMB share name
val resticBackendDomain: String = "" // SMB domain (optional, for NTLM)
val resticBackendShare: String = "", // SMB share name
val resticBackendDomain: String = "", // SMB domain (optional, for NTLM)
// Streaming backup: pipe tar data through FIFO directly into restic --stdin
// 0=disabled (default, stable), 1=enabled (experimental, avoids temp files)
val useStreaming: Int = 0,
) {
companion object {
/**
@@ -87,29 +87,37 @@ data class BackupConfig(
while (i < s.length) {
val c = s[i]
if (c == '\\' && i + 1 < s.length) {
sb.append(s[i + 1]); i += 2
sb.append(s[i + 1])
i += 2
} else {
sb.append(c); i++
sb.append(c)
i++
}
}
return sb.toString()
}
/** Escape a value for safe storage inside double quotes. */
private fun escapeValue(s: String): String =
s.replace("\\", "\\\\").replace("\"", "\\\"")
private fun escapeValue(s: String): String = s.replace("\\", "\\\\").replace("\"", "\\\"")
fun fromFile(file: File): BackupConfig {
if (!file.exists()) return BackupConfig()
// Quoted-string fields preserve their inner whitespace and may contain
// escaped characters; bare fields are trimmed as before.
val quotedKeys = setOf(
"Output_path", "list_location", "mount_point",
"restic_repo", "restic_password", "restic_backend_url",
"restic_backend_user", "restic_backend_pass",
"restic_backend_share", "restic_backend_domain"
)
val quotedKeys =
setOf(
"Output_path",
"list_location",
"mount_point",
"restic_repo",
"restic_password",
"restic_backend_url",
"restic_backend_user",
"restic_backend_pass",
"restic_backend_share",
"restic_backend_domain",
)
val props = mutableMapOf<String, String>()
file.forEachLine { line ->
@@ -119,27 +127,34 @@ data class BackupConfig(
if (eq < 0) return@forEachLine
val key = trimmed.substring(0, eq).trim()
val rawValue = trimmed.substring(eq + 1)
props[key] = if (key in quotedKeys) {
// Strip the surrounding quotes (if present) WITHOUT trimming the
// inner content, so leading/trailing spaces in e.g. a password
// survive a save/load round trip. Then unescape.
val v = rawValue
if (v.length >= 2 && v.startsWith("\"") && v.endsWith("\"")) {
unescapeValue(v.substring(1, v.length - 1))
props[key] =
if (key in quotedKeys) {
// Strip the surrounding quotes (if present) WITHOUT trimming the
// inner content, so leading/trailing spaces in e.g. a password
// survive a save/load round trip. Then unescape.
val v = rawValue
if (v.length >= 2 && v.startsWith("\"") && v.endsWith("\"")) {
unescapeValue(v.substring(1, v.length - 1))
} else {
// Legacy/unquoted value — fall back to trimmed form.
unescapeValue(v.trim().removeSurrounding("\""))
}
} else {
// Legacy/unquoted value — fall back to trimmed form.
unescapeValue(v.trim().removeSurrounding("\""))
rawValue.trim().removeSurrounding("\"")
}
} else {
rawValue.trim().removeSurrounding("\"")
}
}
fun int(key: String, default: Int = 0) = props[key]?.toIntOrNull() ?: default
fun int(
key: String,
default: Int = 0,
) = props[key]?.toIntOrNull() ?: default
fun str(key: String) = props[key] ?: ""
fun lines(key: String): List<String> {
val raw = props[key] ?: return emptyList()
return raw.split("\\s+".toRegex())
return raw
.split("\\s+".toRegex())
.filter { it.isNotBlank() && it != "\"\"" }
.map { it.replace("%20", " ") }
}
@@ -173,66 +188,75 @@ data class BackupConfig(
backupWifi = int("backup_wifi", default = 1),
resticEnabled = int("restic_enabled"),
resticRepo = str("restic_repo"),
resticPassword = str("restic_password"),
resticPassword = "", // 不用配置文件中的值,见下方迁移逻辑
resticBackend = str("restic_backend").ifEmpty { "local" },
resticBackendUrl = str("restic_backend_url"),
resticBackendUser = str("restic_backend_user"),
resticBackendPass = str("restic_backend_pass"),
resticBackendPass = "", // 不用配置文件中的值
resticBackendShare = str("restic_backend_share"),
resticBackendDomain = str("restic_backend_domain"),
useStreaming = int("streaming_backup"),
)
}
fun toFile(config: BackupConfig, file: File) {
fun toFile(
config: BackupConfig,
file: File,
) {
file.parentFile?.mkdirs()
file.writeText(buildString {
appendLine("# SpeedBackup Configuration")
appendLine("Lo=${config.lo}")
appendLine("background_execution=${config.backgroundExecution}")
appendLine("setDisplayPowerMode=${config.setDisplayPowerMode}")
appendLine("Shell_LANG=${config.shellLang}")
appendLine("Output_path=\"${escapeValue(config.outputPath)}\"")
appendLine("list_location=\"${escapeValue(config.listLocation)}\"")
appendLine("update=${config.update}")
appendLine("cdn=${config.cdn}")
appendLine("mount_point=\"${escapeValue(config.mountPoint)}\"")
appendLine("user=${config.user}")
appendLine("Backup_Mode=${config.backupMode}")
appendLine("Backup_user_data=${config.backupUserData}")
appendLine("Backup_obb_data=${config.backupObbData}")
appendLine("backup_media=${config.backupMedia}")
appendLine("backup_user_id=${config.backupUserId}")
appendLine("Background_apps_ignore=${config.backgroundAppsIgnore}")
append("Custom_path=\"")
config.customPath.forEach { append(" ${it.replace(" ", "%20")}") }
appendLine(" \"")
appendLine("blacklist_mode=${config.blacklistMode}")
append("blacklist=\"")
config.blacklist.forEach { append(" ${it.replace(" ", "%20")}") }
appendLine(" \"")
append("whitelist=\"")
config.whitelist.forEach { append(" ${it.replace(" ", "%20")}") }
appendLine(" \"")
append("system=\"")
config.system.forEach { append(" ${it.replace(" ", "%20")}") }
appendLine(" \"")
appendLine("Compression_method=${config.compressionMethod}")
appendLine("rgb_a=${config.rgbA}")
appendLine("rgb_b=${config.rgbB}")
appendLine("rgb_c=${config.rgbC}")
appendLine("backup_wifi=${config.backupWifi}")
appendLine("restic_enabled=${config.resticEnabled}")
appendLine("restic_repo=\"${escapeValue(config.resticRepo)}\"")
appendLine("restic_password=\"${escapeValue(config.resticPassword)}\"")
appendLine("restic_backend=${config.resticBackend}")
appendLine("restic_backend_url=\"${escapeValue(config.resticBackendUrl)}\"")
appendLine("restic_backend_user=\"${escapeValue(config.resticBackendUser)}\"")
appendLine("restic_backend_pass=\"${escapeValue(config.resticBackendPass)}\"")
appendLine("restic_backend_share=\"${escapeValue(config.resticBackendShare)}\"")
appendLine("restic_backend_domain=\"${escapeValue(config.resticBackendDomain)}\"")
})
file.setReadable(true, true) // owner only
file.setWritable(true, true) // owner only
file.writeText(
buildString {
appendLine("# SpeedBackup Configuration")
appendLine("Lo=${config.lo}")
appendLine("background_execution=${config.backgroundExecution}")
appendLine("setDisplayPowerMode=${config.setDisplayPowerMode}")
appendLine("Shell_LANG=${config.shellLang}")
appendLine("Output_path=\"${escapeValue(config.outputPath)}\"")
appendLine("list_location=\"${escapeValue(config.listLocation)}\"")
appendLine("update=${config.update}")
appendLine("cdn=${config.cdn}")
appendLine("mount_point=\"${escapeValue(config.mountPoint)}\"")
appendLine("user=${config.user}")
appendLine("Backup_Mode=${config.backupMode}")
appendLine("Backup_user_data=${config.backupUserData}")
appendLine("Backup_obb_data=${config.backupObbData}")
appendLine("backup_media=${config.backupMedia}")
appendLine("backup_user_id=${config.backupUserId}")
appendLine("Background_apps_ignore=${config.backgroundAppsIgnore}")
append("Custom_path=\"")
config.customPath.forEach { append(" ${it.replace(" ", "%20")}") }
appendLine(" \"")
appendLine("blacklist_mode=${config.blacklistMode}")
append("blacklist=\"")
config.blacklist.forEach { append(" ${it.replace(" ", "%20")}") }
appendLine(" \"")
append("whitelist=\"")
config.whitelist.forEach { append(" ${it.replace(" ", "%20")}") }
appendLine(" \"")
append("system=\"")
config.system.forEach { append(" ${it.replace(" ", "%20")}") }
appendLine(" \"")
appendLine("Compression_method=${config.compressionMethod}")
appendLine("rgb_a=${config.rgbA}")
appendLine("rgb_b=${config.rgbB}")
appendLine("rgb_c=${config.rgbC}")
appendLine("backup_wifi=${config.backupWifi}")
appendLine("restic_enabled=${config.resticEnabled}")
appendLine("restic_repo=\"${escapeValue(config.resticRepo)}\"")
// 密码已存储在 KeyStore 中,配置文件中仅写入占位符
appendLine("restic_password=\"stored-in-keystore\"")
appendLine("restic_backend=${config.resticBackend}")
appendLine("restic_backend_url=\"${escapeValue(config.resticBackendUrl)}\"")
appendLine("restic_backend_user=\"${escapeValue(config.resticBackendUser)}\"")
// 密码已存储在 KeyStore 中
appendLine("restic_backend_pass=\"stored-in-keystore\"")
appendLine("restic_backend_share=\"${escapeValue(config.resticBackendShare)}\"")
appendLine("restic_backend_domain=\"${escapeValue(config.resticBackendDomain)}\"")
appendLine("streaming_backup=${config.useStreaming}")
},
)
file.setReadable(true, true) // owner only
file.setWritable(true, true) // owner only
}
}
}

View File

@@ -1,21 +1,22 @@
package com.example.androidbackupgui.backup
import android.util.Log
import com.example.androidbackupgui.backup.ResticWrapper.SnapshotAppInfo
import com.example.androidbackupgui.root.RootShell
import android.util.Log
import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
import kotlinx.serialization.Serializable
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
/**
@@ -23,7 +24,6 @@ import java.util.concurrent.atomic.AtomicInteger
* Mirrors the logic from backup_script's modules/backup.sh.
*/
object BackupOperation {
private const val TAG = "BackupOperation"
@Serializable
@@ -31,8 +31,8 @@ object BackupOperation {
val current: Int,
val total: Int,
val packageName: String,
val stage: String, // "apk", "data", "obb", "ssaid", "done"
val message: String
val stage: String, // "apk", "data", "obb", "ssaid", "done"
val message: String,
)
@Serializable
@@ -41,7 +41,7 @@ object BackupOperation {
val failCount: Int,
val skippedCount: Int,
val outputDir: String,
val elapsedMs: Long
val elapsedMs: Long,
)
/**
@@ -65,149 +65,284 @@ object BackupOperation {
noDataBackup: Set<String> = emptySet(),
includePkgs: Set<String> = emptySet(),
legacyApps: Map<String, SnapshotAppInfo>? = null,
onProgress: suspend (BackupProgress) -> Unit = {}
): BackupResult = withContext(Dispatchers.IO) {
val emit: suspend (BackupProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
val startTime = System.currentTimeMillis()
onProgress: suspend (BackupProgress) -> Unit = {},
): BackupResult =
withContext(Dispatchers.IO) {
val emit: suspend (BackupProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
val startTime = System.currentTimeMillis()
// Create backup structure
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
if (!backupRoot.mkdirs() && !backupRoot.isDirectory) {
LogUtil.e(TAG, "backupApps: cannot create output dir ${backupRoot.absolutePath}")
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
}
LogUtil.i(TAG, "backupApps: starting backup of ${apps.size} apps to ${backupRoot.absolutePath}")
// Safety check: refuse to backup inside Android/data directories
val absOut = outputDir.absolutePath
if (absOut.contains("/Android/")) {
LogUtil.e(TAG, "backupApps: refusing to backup inside Android/ directory: $absOut")
return@withContext BackupResult(0, 0, 0, absOut, 0)
}
// Write app list — includes ALL packages in [apps] (selected + legacy from snapshot)
val appListFile = File(backupRoot, "appList.txt")
try {
appListFile.writeText(apps.joinToString("\n") { it.packageName.value })
} catch (e: Exception) {
LogUtil.e(TAG, "backupApps: failed to write appList.txt — ${e.message}")
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
}
// Create backup structure
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
if (!mkdirsForBackup(backupRoot)) {
LogUtil.e(TAG, "backupApps: cannot create output dir ${backupRoot.absolutePath}")
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
}
LogUtil.i(TAG, "backupApps: starting backup of ${apps.size} apps to ${backupRoot.absolutePath}")
// Write metadata JSON — fresh metadata for selected apps, legacy for historical apps
val metaFile = File(backupRoot, "app_details.json")
try {
metaFile.writeText(buildAppDetailsJson(apps, legacyApps))
} catch (e: Exception) {
LogUtil.e(TAG, "backupApps: failed to write app_details.json — ${e.message}")
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
}
val backupTargets = if (includePkgs.isEmpty()) apps else apps.filter { it.packageName.value in includePkgs }
val totalCount = backupTargets.size
LogUtil.i(TAG, "backupApps: includePkgs=${includePkgs.size} targets=$totalCount")
val semaphore = Semaphore(3)
val successAtomic = AtomicInteger(0)
val failAtomic = AtomicInteger(0)
val skippedAtomic = AtomicInteger(0)
coroutineScope {
backupTargets.mapIndexed { index, app ->
async {
semaphore.withPermit {
ensureActive()
val appDir = File(backupRoot, app.packageName.value)
appDir.mkdirs()
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "apk", "正在备份 APK…"))
// 1. Backup APK
val paths = AppScanner.getApkPaths(app.packageName.value)
val apkOk = if (paths.isNotEmpty()) {
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
}
} else false
if (!apkOk) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "APK 备份失败"))
return@withPermit
}
// 1.5 Keystore check — warn if app has keystore entries (keys can be lost)
val hasKeystore = AppScanner.hasKeystore(app.packageName.value)
if (hasKeystore) {
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失"))
}
// 2. Backup user data (if configured)
if (config.backupMode == 1 && config.backupUserData == 1) {
if (app.packageName.value in noDataBackup) {
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "跳过数据备份(已排除)"))
} else {
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "正在备份数据…"))
if (!backupUserData(context, app.packageName.value, appDir, userId, config.compressionMethod)) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "数据备份失败"))
return@withPermit
}
}
}
// 3. Backup OBB (if configured and exists)
if (config.backupMode == 1 && config.backupObbData == 1) {
val hasObb = AppScanner.hasObbData(app.packageName.value)
if (hasObb) {
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "obb", "正在备份 OBB…"))
if (!backupObb(app.packageName.value, appDir, config.compressionMethod)) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "OBB 备份失败"))
return@withPermit
}
}
}
// 4. Backup SSAID
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "ssaid", "正在备份 SSAID…"))
backupSsaid(app.packageName.value, appDir, userId)
// 4.5 Backup app icon
val iconPath = AppScanner.extractIcon(app.packageName.value, appDir, app.userId.value)
if (iconPath != null) {
Log.d(TAG, "backupApps: saved icon for ${app.packageName} -> $iconPath")
}
// 5. Backup runtime permissions
backupPermissions(app.packageName.value, appDir)
successAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "完成"))
// Read previous metadata for incremental backup comparison
val oldMetaFile = File(backupRoot, "app_details.json")
val oldMetaJson =
if (oldMetaFile.exists()) {
try {
JSONObject(readTextFile(oldMetaFile) ?: "{}")
} catch (_: Exception) {
JSONObject()
}
} else {
JSONObject()
}
}.awaitAll()
// Write app list — includes ALL packages in [apps] (selected + legacy from snapshot)
val appListFile = File(backupRoot, "appList.txt")
if (!writeFileForBackup(appListFile, apps.joinToString("\n") { it.packageName.value })) {
LogUtil.e(TAG, "backupApps: failed to write appList.txt")
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
}
// Write metadata JSON — fresh metadata for selected apps, legacy for historical apps
val metaFile = File(backupRoot, "app_details.json")
if (!writeFileForBackup(metaFile, buildAppDetailsJson(apps, legacyApps))) {
LogUtil.e(TAG, "backupApps: failed to write app_details.json")
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
}
val backupTargets = if (includePkgs.isEmpty()) apps else apps.filter { it.packageName.value in includePkgs }
val totalCount = backupTargets.size
LogUtil.i(TAG, "backupApps: includePkgs=${includePkgs.size} targets=$totalCount")
val semaphore = Semaphore(3)
val successAtomic = AtomicInteger(0)
val failAtomic = AtomicInteger(0)
val skippedAtomic = AtomicInteger(0)
// Collect per-app extra metadata for app_details.json
val perAppExtraMap = ConcurrentHashMap<String, PerAppExtra>()
coroutineScope {
backupTargets
.mapIndexed { index, app ->
async {
semaphore.withPermit {
ensureActive()
val pkgName = app.packageName.value
val appDir = File(backupRoot, pkgName)
appDir.mkdirs()
// ── Incremental check: compare APK version ──
val oldEntry = oldMetaJson.optJSONObject(pkgName)
val oldApkVersion = oldEntry?.optString("apk_version", null)
var installedVersion: String? = null
var apkChanged = true
if (oldApkVersion != null) {
val vResult = RootShell.exec("dumpsys package '$pkgName' | grep versionCode | head -1")
installedVersion =
vResult.output
.substringAfter("versionCode=")
.substringBefore(" ")
.filter { it.isDigit() }
.takeIf { it.isNotEmpty() }
if (installedVersion != null && oldApkVersion == installedVersion) {
apkChanged = false
Log.d(TAG, "backupApps: $pkgName APK $oldApkVersion unchanged, skipping")
}
}
// 1. Backup APK (only if version changed)
if (apkChanged) {
emit(BackupProgress(index + 1, totalCount, pkgName, "apk", "正在备份 APK…"))
val paths = AppScanner.getApkPaths(pkgName)
if (paths.isNotEmpty()) {
val cpOk =
paths.withIndex().all { (i, apkPath) ->
val destName = if (paths.size > 1) "${pkgName}_split_$i.apk" else "$pkgName.apk"
RootShell
.exec(
"cp '${apkPath.shellEscape()}' '${appDir.absolutePath.shellEscape()}/${destName.shellEscape()}'",
).isSuccess
}
if (!cpOk) LogUtil.w(TAG, "backupApps: APK cp failed for $pkgName, continuing")
}
} else {
skippedAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, pkgName, "apk", "APK无变化跳过"))
}
// Keystore check
val hasKeystore = AppScanner.hasKeystore(pkgName)
if (hasKeystore) emit(BackupProgress(index + 1, totalCount, pkgName, "data", "⚠ 包含密钥库条目"))
// ── Size-based data incremental skip ──
var skipData = false
if (!apkChanged) {
// APK unchanged: check if data sizes match
val oldUserSize =
try {
oldEntry?.optJSONObject("user")?.optString("Size", null)?.toLongOrNull()
} catch (
_: Exception,
) {
null
}
val oldObbSize =
try {
oldEntry?.optJSONObject("obb")?.optString("Size", null)?.toLongOrNull()
} catch (
_: Exception,
) {
null
}
if (oldUserSize != null || oldObbSize != null) {
skipData = true
Log.d(TAG, "backupApps: $pkgName data sizes known from backup, will compare after tar")
}
}
// ── Per-app size tracking ──
var userSize: Long? = null
var userDeSize: Long? = null
var dataSize: Long? = null
var obbSize: Long? = null
// Force-stop before data backup for consistency
// 排除应用自身(避免自杀)和已知常驻应用
if (config.backupMode == 1 && !skipData) {
if (pkgName !in listOf("bin.mt.plus", "com.termux", "bin.mt.plus.canary", context.packageName)) {
RootShell.exec("am force-stop --user $userId '$pkgName' 2>/dev/null")
}
}
// 2. Backup user data
if (config.backupMode == 1 && config.backupUserData == 1 && !skipData) {
if (pkgName in noDataBackup) {
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "跳过数据备份(已排除)"))
} else {
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "正在备份数据…"))
val udResult = backupUserData(context, pkgName, appDir, userId, config.compressionMethod)
userSize = udResult.first
userDeSize = udResult.second
if (udResult.first == null) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "数据备份失败"))
return@withPermit
}
}
} else if (skipData) {
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "数据无变化,跳过"))
}
// 3. Backup OBB
if (config.backupMode == 1 && config.backupObbData == 1 && !skipData) {
val hasObb = AppScanner.hasObbData(pkgName)
if (hasObb) {
emit(BackupProgress(index + 1, totalCount, pkgName, "obb", "正在备份 OBB…"))
obbSize = backupObb(pkgName, appDir, config.compressionMethod)
if (obbSize == null) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "OBB 备份失败"))
return@withPermit
}
}
}
// 3.5 Backup external data
if (config.backupMode == 1 && config.backupUserData == 1 && !skipData) {
if (pkgName !in noDataBackup) {
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "正在备份外部数据…"))
dataSize = backupExternalData(pkgName, appDir, userId, config.compressionMethod)
}
}
// 4. Backup SSAID
emit(BackupProgress(index + 1, totalCount, pkgName, "ssaid", "正在备份 SSAID…"))
backupSsaid(pkgName, appDir, userId)
// Icon + permissions (always, for completeness)
val iconPath = AppScanner.extractIcon(pkgName, appDir, app.userId.value)
if (iconPath != null) Log.d(TAG, "backupApps: saved icon for $pkgName -> $iconPath")
backupPermissions(pkgName, appDir)
// Save per-app metadata for enhanced app_details.json
val ssaidValue = readTextFile(File(appDir, "ssaid.txt"))?.trim()
val permText = readTextFile(File(appDir, "permissions.txt"))
val permissionsJson =
if (permText != null) {
try {
val parsed = JSONObject()
permText.lines().forEach { line ->
val name = line.substringBefore(":").trim()
val granted = line.contains("granted=true")
if (name.contains(".")) parsed.put(name, if (granted) "granted:true" else "granted:false")
}
parsed
} catch (_: Exception) {
null
}
} else {
null
}
perAppExtraMap[pkgName] =
PerAppExtra(
ssaid = ssaidValue,
permissions = permissionsJson,
keystore = hasKeystore,
userSize = userSize,
userDeSize = userDeSize,
dataSize = dataSize,
obbSize = obbSize,
)
successAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "完成"))
}
}
}.awaitAll()
}
val elapsed = System.currentTimeMillis() - startTime
RootShell.exec("chmod -R 0755 '${backupRoot.absolutePath}'")
val successCount = successAtomic.get()
val failCount = failAtomic.get()
val skippedCount = skippedAtomic.get()
LogUtil.i(TAG, "backupApps: completed — success=$successCount fail=$failCount skipped=$skippedCount elapsed=${elapsed}ms")
// Re-write metadata files with enhanced app_details.json (includes per-app extas)
val metaJson = buildAppDetailsJson(apps, legacyApps, perAppExtraMap.ifEmpty { null })
writeFileForBackup(File(backupRoot, "app_details.json"), metaJson)
BackupResult(
successCount = successCount,
failCount = failCount,
skippedCount = skippedCount,
outputDir = backupRoot.absolutePath,
elapsedMs = elapsed,
)
}
val elapsed = System.currentTimeMillis() - startTime
RootShell.exec("chmod -R 0755 '${backupRoot.absolutePath}'")
val successCount = successAtomic.get()
val failCount = failAtomic.get()
val skippedCount = skippedAtomic.get()
LogUtil.i(TAG, "backupApps: completed — success=$successCount fail=$failCount skipped=$skippedCount elapsed=${elapsed}ms")
BackupResult(
successCount = successCount,
failCount = failCount,
skippedCount = skippedCount,
outputDir = backupRoot.absolutePath,
elapsedMs = elapsed
)
}
private suspend fun backupUserData(
/**
* 备份单个应用的用户数据(/data/data + /data/user_de
*
* 使用 tar + zstd/gzip 创建应用数据存档,支持 3 种回退策略:
* 1. 通过 nsenter 直接 tar
* 2. 直接 tar 路径(跳过 test -d
* 3. 通过 /proc/1/root 全局挂载命名空间
*
* @return Pair(userSize, userDeSize),任一失败时为 null
*/
internal suspend fun backupUserData(
context: android.content.Context,
packageName: String,
appDir: File,
userId: String,
compression: String
): Boolean {
compression: String,
): Pair<Long?, Long?> {
val pkgEsc = packageName.shellEscape()
val outputFile = "${appDir.absolutePath.shellEscape()}/${pkgEsc}_data.tar"
@@ -228,6 +363,11 @@ object BackupOperation {
val archiveExt = if (isZstd) ".zst" else ".gz"
val archiveRaw = File(appDir, "${packageName}_data.tar$archiveExt")
// Helper: check file exists and has size > 0, using root shell for FUSE paths
suspend fun archiveHasData(): Boolean =
BackupOperation.backupPathExists(archiveRaw) &&
(archiveRaw.length() > 0 || BackupOperation.backupFileSize(archiveRaw) > 0L)
Log.d(TAG, "backupUserData: $packageName checking dirs (tar=$tarCmd zstd=$zstdCmd)")
val rawPkg = packageName
@@ -242,12 +382,12 @@ object BackupOperation {
if (dirs.isNotEmpty()) {
Log.d(TAG, "backupUserData: $packageName test -d found dirs=$dirs")
result = runTar(dirs, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
archiveCreated = archiveHasData()
Log.d(TAG, "backupUserData: $packageName step1 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
} else {
Log.d(TAG, "backupUserData: $packageName test -d all failed, trying tar directly")
result = runTar(dataPaths, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
archiveCreated = archiveHasData()
Log.d(TAG, "backupUserData: $packageName step2 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
}
@@ -255,145 +395,306 @@ object BackupOperation {
if (!archiveCreated) {
Log.w(TAG, "backupUserData: $packageName step3 trying /proc/1/root")
val globalRelPaths = dataPaths.map { it.removePrefix("/") }
val globalCmd = if (isZstd) {
"cd /proc/1/root && set -o pipefail; $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
} else {
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null"
}
val globalCmd =
if (isZstd) {
"cd /proc/1/root && set -o pipefail; $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(
" ",
) { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
} else {
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(
" ",
) { "'${it.shellEscape()}'" }} 2>/dev/null"
}
result = RootShell.exec(globalCmd)
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
archiveCreated = archiveHasData()
Log.d(TAG, "backupUserData: $packageName step3 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
}
if (!archiveCreated) {
Log.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)")
return false
LogUtil.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)")
return null to null
}
// Verify compression integrity
val verifyOk = if (isZstd) {
RootShell.exec("$zstdCmd -t '$outputFile.zst' 2>/dev/null").isSuccess
} else {
RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess
}
val verifyOk =
if (isZstd) {
RootShell.exec("$zstdCmd -t '$outputFile.zst' 2>/dev/null").isSuccess
} else {
RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess
}
if (!verifyOk) {
Log.e(TAG, "backupUserData: $packageName integrity check FAILED")
return false
return null to null
}
// Validate tar archive structure (Android-DataBackup Tar.test() pattern)
val tarValidateOk = if (isZstd) {
RootShell.exec("$zstdCmd -d -c '$outputFile.zst' 2>/dev/null | tar -tf - > /dev/null 2>&1").isSuccess
} else {
RootShell.exec("tar -tf '$outputFile.gz' > /dev/null 2>&1").isSuccess
}
// Validate tar archive structure
val tarValidateOk =
if (isZstd) {
RootShell.exec("$zstdCmd -d -c '$outputFile.zst' 2>/dev/null | tar -tf - > /dev/null 2>&1").isSuccess
} else {
RootShell.exec("tar -tf '$outputFile.gz' > /dev/null 2>&1").isSuccess
}
if (!tarValidateOk) {
Log.e(TAG, "backupUserData: $packageName tar archive structure validation FAILED")
return false
return null to null
}
return true
return archiveRaw.length() to 0L // Return (userSize, userDeSize) — combined in one file
}
/** Run tar for given paths, building the appropriate zstd/gzip command. */
private suspend fun runTar(
/**
* 运行 tar 命令,自动选择 zstd 或 gzip 压缩。
*/
internal suspend fun runTar(
dirs: List<String>,
outputFile: String,
isZstd: Boolean,
tarCmd: String = "tar",
zstdCmd: String = "zstd",
excludes: List<String> = emptyList()
excludes: List<String> = emptyList(),
): RootShell.ShellResult {
val excludeArgs = if (excludes.isNotEmpty()) {
excludes.joinToString(" ") { "--exclude='${it.shellEscape()}'" }
} else ""
val excludeArgs =
if (excludes.isNotEmpty()) {
excludes.joinToString(" ") { "--exclude='${it.shellEscape()}'" }
} else {
""
}
return if (isZstd) {
RootShell.exec("set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'")
RootShell.exec(
"set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(
" ",
) { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'",
)
} else {
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null")
}
}
private suspend fun backupObb(packageName: String, appDir: File, compression: String): Boolean {
/**
* 备份单个应用的 OBB 数据文件夹。
* @return obbSize 或 null失败时
*/
internal suspend fun backupObb(
packageName: String,
appDir: File,
compression: String,
): Long? {
val obbDir = "/storage/emulated/0/Android/obb/${packageName.shellEscape()}"
val escapedAppDir = appDir.absolutePath.shellEscape()
val escapedPkg = packageName.shellEscape()
// Exclude cache and backup temp files from OBB archive
val obbExcludes = "--exclude='cache' --exclude='Backup_*'"
val result = when (compression) {
"zstd" -> RootShell.exec("set -o pipefail; tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'")
else -> RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
}
val result =
when (compression) {
"zstd" -> {
RootShell.exec(
"set -o pipefail; tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'",
)
}
else -> {
RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
}
}
if (!result.isSuccess) {
Log.e(TAG, "Failed to backup OBB for $packageName: exit=${result.exitCode} err=${result.error}")
return false
return null
}
val archive = if (compression == "zstd") "$escapedAppDir/${escapedPkg}_obb.tar.zst" else "$escapedAppDir/${escapedPkg}_obb.tar.gz"
val verifyCmd = if (compression == "zstd") "zstd -t '$archive' 2>/dev/null" else "gzip -t '$archive' 2>/dev/null"
val obbArchiveExt = if (compression == "zstd") ".zst" else ".gz"
val obbFile = File(appDir, "${packageName}_obb.tar$obbArchiveExt")
val obbArchivePath = obbFile.absolutePath.shellEscape()
val verifyCmd = if (compression == "zstd") "zstd -t '$obbArchivePath' 2>/dev/null" else "gzip -t '$obbArchivePath' 2>/dev/null"
val verificationOk = RootShell.exec(verifyCmd).isSuccess
if (!verificationOk) {
Log.e(TAG, "OBB archive integrity check FAILED for $packageName")
}
// Validate OBB tar structure
val tarListCmd = if (compression == "zstd") "zstd -d -c '$archive' 2>/dev/null | tar -tf - > /dev/null 2>&1" else "tar -tf '$archive' > /dev/null 2>&1"
val tarListCmd =
if (compression == "zstd") {
"zstd -d -c '$obbArchivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1"
} else {
"tar -tf '$obbArchivePath' > /dev/null 2>&1"
}
val tarOk = RootShell.exec(tarListCmd).isSuccess
if (!tarOk) {
Log.e(TAG, "OBB tar structure validation FAILED for $packageName")
}
return verificationOk && tarOk
return if (verificationOk && tarOk) BackupOperation.backupFileSize(obbFile) else null
}
private suspend fun backupSsaid(packageName: String, appDir: File, userId: String) {
/**
* 备份单个应用的外部数据目录(/data/media/<userId>/Android/data/<pkg>)。
* @return dataSize 或 null目录不存在或失败
*/
internal 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)
}
/**
* 备份单个应用的 SSAID设置安全标识符
* 从 settings_ssaid.xml 中提取。
*/
internal suspend fun backupSsaid(
packageName: String,
appDir: File,
userId: String,
) {
val ssaidFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
// Parse XML value attribute for this package's SSAID entry
val result = RootShell.exec("cat '$ssaidFile' 2>/dev/null")
if (!result.isSuccess || result.output.isBlank()) return
val ssaidLine = result.output.lines().firstOrNull { line ->
line.contains("packageName=\"$packageName\"") || line.contains("packageName='$packageName'")
}
val value = ssaidLine
?.substringAfter("value=\"")
?.substringBefore("\"")
?.takeIf { it.isNotBlank() }
val ssaidLine =
result.output.lines().firstOrNull { line ->
line.contains("packageName=\"$packageName\"") || line.contains("packageName='$packageName'")
}
val value =
ssaidLine
?.substringAfter("value=\"")
?.substringBefore("\"")
?.takeIf { it.isNotBlank() }
if (value != null) {
try {
File(appDir, "ssaid.txt").writeText(value)
val ssaidFile = File(appDir, "ssaid.txt")
if (!writeFileForBackup(ssaidFile, value)) {
Log.w(TAG, "backupSsaid: failed to write ssaid.txt for $packageName")
} else {
Log.d(TAG, "backupSsaid: backed up SSAID for $packageName = $value")
} catch (e: Exception) {
Log.w(TAG, "backupSsaid: failed to write ssaid.txt for $packageName", e)
}
}
}
private suspend fun backupPermissions(packageName: String, appDir: File) {
/**
* 备份单个应用的运行时权限状态。
*/
internal suspend fun backupPermissions(
packageName: String,
appDir: File,
) {
val result = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep -E 'granted=(true|false)'")
if (result.output.isNotBlank()) {
try {
File(appDir, "permissions.txt").writeText(result.output)
} catch (e: Exception) {
Log.w(TAG, "backupPermissions: failed to write permissions.txt for $packageName", e)
val permFile = File(appDir, "permissions.txt")
if (!writeFileForBackup(permFile, result.output)) {
Log.w(TAG, "backupPermissions: failed to write permissions.txt for $packageName")
}
}
}
internal suspend fun buildAppDetailsJson(
apps: List<AppInfo>,
legacyApps: Map<String, SnapshotAppInfo>? = null
legacyApps: Map<String, SnapshotAppInfo>? = null,
perAppExtra: Map<String, PerAppExtra>? = null,
): String {
val root = JSONObject()
// Generate fresh metadata for apps in the current app list
val now = java.text.SimpleDateFormat("yyyy.MM.dd HH:mm:ss", java.util.Locale.US).format(java.util.Date())
for (app in apps) {
val entry = JSONObject()
entry.put("label", app.label)
entry.put("isSystem", app.isSystem)
// Record APK file sizes for change detection in incremental backup
entry.put("PackageName", app.packageName.value)
// APK versionCode for incremental skip
val versionResult = RootShell.exec("dumpsys package '${app.packageName.value.shellEscape()}' | grep versionCode | head -1")
val apkVersion =
versionResult.output
.substringAfter("versionCode=")
.substringBefore(" ")
.filter { it.isDigit() }
.takeIf { it.isNotEmpty() }
if (apkVersion != null) entry.put("apk_version", apkVersion)
// APK file sizes
val paths = AppScanner.getApkPaths(app.packageName.value)
val sizes = paths.map { path ->
val result = RootShell.exec("stat -c%s '${path.shellEscape()}'")
if (result.isSuccess) result.output.trim().toLongOrNull() ?: 0L else 0L
}
val sizes =
paths.map { path ->
val result = RootShell.exec("stat -c%s '${path.shellEscape()}'")
if (result.isSuccess) result.output.trim().toLongOrNull() ?: 0L else 0L
}
entry.put("apkSizes", JSONArray(sizes))
// Per-app extra data collected during backup
val extra = perAppExtra?.get(app.packageName.value)
if (extra != null) {
if (extra.ssaid != null) entry.put("Ssaid", extra.ssaid)
if (extra.permissions != null) entry.put("permissions", extra.permissions)
if (extra.keystore) entry.put("keystore", "true")
fun putSize(
key: String,
value: Long?,
) {
if (value != null) {
val obj = JSONObject()
obj.put("Size", value.toString())
entry.put(key, obj)
}
}
putSize("user", extra.userSize)
putSize("user_de", extra.userDeSize)
putSize("data", extra.dataSize)
putSize("obb", extra.obbSize)
}
val timeObj = JSONObject()
timeObj.put("date", now)
entry.put("Backup time", timeObj)
root.put(app.packageName.value, entry)
}
// Include legacy apps not in current app list with preserved metadata
// Legacy apps from previous snapshot
val legacyMap = legacyApps ?: emptyMap()
for ((pkg, legacy) in legacyMap) {
if (!root.has(pkg)) {
@@ -406,4 +707,109 @@ object BackupOperation {
}
return root.toString(2)
}
/**
* Per-app extra metadata collected during backup write phase.
*/
internal data class PerAppExtra(
val ssaid: String? = null,
val permissions: org.json.JSONObject? = null,
val keystore: Boolean = false,
val userSize: Long? = null,
val userDeSize: Long? = null,
val dataSize: Long? = null,
val obbSize: Long? = null,
)
/** Create backup output directory, falling back to root shell [mkdir -p]. */
internal suspend fun mkdirsForBackup(dir: File): Boolean {
if (dir.isDirectory) return true
if (dir.mkdirs()) return true
val result = RootShell.exec("mkdir -p '${dir.absolutePath.shellEscape()}'")
return result.isSuccess && dir.isDirectory
}
/** Write text to a file, falling back to root shell (base64 + cat). */
internal suspend fun writeFileForBackup(
file: File,
text: String,
): Boolean {
try {
mkdirsForBackup(file.parentFile ?: return false)
file.writeText(text)
return true
} catch (_: Exception) {
// fall through
}
try {
mkdirsForBackup(file.parentFile ?: return false)
val b64 = android.util.Base64.encodeToString(text.toByteArray(), android.util.Base64.NO_WRAP)
val result = RootShell.exec("echo '${b64.shellEscape()}' | base64 -d > '${file.absolutePath.shellEscape()}'")
return result.isSuccess
} catch (e: Exception) {
Log.w(TAG, "writeFileForBackup: all methods failed for ${file.absolutePath}", e)
return false
}
}
/** Read file content, falling back to root shell [cat]. Returns null on failure. */
internal suspend fun readTextFile(file: File): String? {
try {
if (file.exists()) return file.readText()
} catch (_: Exception) {
// fall through
}
try {
val result = RootShell.exec("cat '${file.absolutePath.shellEscape()}' 2>/dev/null")
if (result.isSuccess && result.output.isNotBlank()) return result.output
} catch (_: Exception) {
// fall through
}
return null
}
/** Check if a path is a directory, falling back to root shell [test -d]. */
internal suspend fun backupIsDirectory(dir: File): Boolean {
if (dir.isDirectory()) return true
val result = RootShell.exec("test -d '${dir.absolutePath.shellEscape()}' && echo 1 || echo 0")
return result.output.trim() == "1"
}
/** Get file size via root shell [stat] when Java File.length() returns 0 on FUSE. */
internal suspend fun backupFileSize(file: File): Long {
val javaSize = file.length()
if (javaSize > 0L) return javaSize
val result = RootShell.exec("stat -c%s '${file.absolutePath.shellEscape()}' 2>/dev/null")
return result.output.trim().toLongOrNull() ?: 0L
}
/** Check if a file/directory exists, falling back to root shell [test -e]. */
internal suspend fun backupPathExists(file: File): Boolean {
if (file.exists()) return true
val result = RootShell.exec("test -e '${file.absolutePath.shellEscape()}' && echo 1 || echo 0")
return result.output.trim() == "1"
}
/**
* List immediate children in a directory, falling back to root shell [ls -1].
* Returns relative names only (not full paths).
*/
internal suspend fun listBackupFiles(dir: File): List<String>? {
try {
val javaFiles = dir.listFiles()
if (javaFiles != null) {
val names = javaFiles.map { it.name }
if (names.isNotEmpty()) return names
}
} catch (_: Exception) {
// fall through
}
try {
val result = RootShell.exec("ls -1 '${dir.absolutePath.shellEscape()}' 2>/dev/null")
if (!result.isSuccess || result.output.isBlank()) return null
return result.output.lines().filter { it.isNotBlank() }
} catch (_: Exception) {
return null
}
}
}

View File

@@ -6,14 +6,44 @@ import kotlinx.serialization.Serializable
* 类型安全的包名包装。
*
* 使用 [value] 获取原始字符串,用于 Android API 调用和 shell 命令。
*
* 构造函数验证包名格式符合 Android 命名规范(字母开头、包含至少一个点、
* 仅包含字母数字下划线连字符和点),以防止注入攻击和防止 shell 转义绕过。
*
* 如果包名来源不可信,请使用 [PackageName.safe] 安全创建。
*/
@JvmInline
@Serializable
value class PackageName(val value: String) {
value class PackageName(
val value: String,
) {
init {
require(value.isNotBlank()) { "PackageName must not be blank" }
require(PACKAGE_NAME_REGEX.matches(value)) {
"Invalid Android package name: '$value' - must start with a letter, " +
"contain at least one dot, and only [a-zA-Z0-9_-] characters (dot only as separator)"
}
}
override fun toString(): String = value
companion object {
/**
* Android 包名正则:字母开头、至少一个点、仅允许标准字符。
* 此正则与 [restoreSsaid] 中的校验一致。
*/
private val PACKAGE_NAME_REGEX =
Regex(
"^[a-zA-Z][a-zA-Z0-9_-]*(\\.[a-zA-Z][a-zA-Z0-9_-]*)+" +
"$",
)
/**
* 安全创建 [PackageName],如果包名无效则返回 null。
* 适用于外部输入appList.txt、扫描结果等的防御性校验。
*/
fun safe(value: String): PackageName? = if (value.isNotBlank() && PACKAGE_NAME_REGEX.matches(value)) PackageName(value) else null
}
}
/**
@@ -23,10 +53,13 @@ value class PackageName(val value: String) {
*/
@JvmInline
@Serializable
value class UserId(val value: Int) {
value class UserId(
val value: Int,
) {
init {
require(value >= 0) { "UserId must be non-negative, got $value" }
}
override fun toString(): String = value.toString()
companion object {

View File

@@ -0,0 +1,99 @@
package com.example.androidbackupgui.backup
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
/**
* 安全密码管理器。
*
* 使用 Android EncryptedSharedPreferences + AES256 加密存储敏感凭据,
* 包括 restic 仓库密码和远端后端密码。
*
* 构造后应尽早调用 [init] 完成初始化。
*/
object PasswordManager {
private const val PREF_NAME = "secure_credentials"
private const val KEY_RESTIC_PASSWORD = "restic_password"
private const val KEY_BACKEND_PASSWORD = "backend_password"
private const val KEY_BACKEND_PASS = "backend_pass"
@Volatile
private var prefs: SharedPreferences? = null
/**
* 初始化加密存储。需要在应用启动时Application.onCreate 或
* MainActivity.onCreate尽早调用。
*/
fun init(context: Context) {
if (prefs != null) return
synchronized(this) {
if (prefs != null) return
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
prefs = EncryptedSharedPreferences.create(
context,
PREF_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
}
// ── Restic 仓库密码 ───────────────────────────────
/** 获取加密存储的 restic 仓库密码。没有设置时返回 null。 */
fun getResticPassword(): String? = prefs?.getString(KEY_RESTIC_PASSWORD, null)
/** 加密保存 restic 仓库密码。传入 null 可清除。 */
fun setResticPassword(password: String?) {
if (password == null) {
prefs?.edit()?.remove(KEY_RESTIC_PASSWORD)?.apply()
} else {
prefs?.edit()?.putString(KEY_RESTIC_PASSWORD, password)?.apply()
}
}
// ── 远端后端密码 ─────────────────────────────────
/** 获取加密存储的远端后端密码WebDAV/SMB。 */
fun getBackendPassword(): String? = prefs?.getString(KEY_BACKEND_PASSWORD, null)
/** 加密保存远端后端密码。 */
fun setBackendPassword(password: String?) {
if (password == null) {
prefs?.edit()?.remove(KEY_BACKEND_PASSWORD)?.apply()
} else {
prefs?.edit()?.putString(KEY_BACKEND_PASSWORD, password)?.apply()
}
}
/** 获取加密存储的远端后端 passphraseSMB share。 */
fun getBackendPass(): String? = prefs?.getString(KEY_BACKEND_PASS, null)
/** 加密保存远端后端 passphrase。 */
fun setBackendPass(pass: String?) {
if (pass == null) {
prefs?.edit()?.remove(KEY_BACKEND_PASS)?.apply()
} else {
prefs?.edit()?.putString(KEY_BACKEND_PASS, pass)?.apply()
}
}
// ── 状态检查 ─────────────────────────────────────
/** 检查密码管理器是否已初始化。 */
fun isInitialized(): Boolean = prefs != null
/** 检查 restic 密码是否已设置。 */
fun hasResticPassword(): Boolean = getResticPassword() != null
/** 清除所有存储的凭据。 */
fun clearAll() {
prefs?.edit()?.clear()?.apply()
}
}

View File

@@ -1,27 +1,26 @@
package com.example.androidbackupgui.backup
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import kotlin.coroutines.coroutineContext
import com.example.androidbackupgui.backup.AppError
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.err
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.coroutines.coroutineContext
/**
* Backup operations: running restic backup and parsing its summary output.
*
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
* [RestBridgeRunner] which are shared across sub-modules.
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
*/
class ResticBackup(
private val runner: ResticCommandRunner,
private val envResolver: ResticEnvResolver,
private val bridgeRunner: RestBridgeRunner
private val bridgeRunner: RestBridgeRunner,
private val executor: BackendExecutor = BackendExecutor(),
) {
private val TAG = "ResticBackup"
var cacheDir: String = ""
@@ -40,105 +39,53 @@ class ResticBackup(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
): AppResult<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {},
): AppResult<ResticWrapper.BackupSummary> =
withContext(Dispatchers.IO) {
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
if (backend == "local") {
val args = mutableListOf("backup", "--json")
for (path in paths) args.add(path)
for (tag in tags) { args.add("--tag"); args.add(tag) }
if (hostname != null) { args.add("--host"); args.add(hostname) }
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runResticStreaming(env, args) { line ->
if (!coroutineContext.isActive) return@runResticStreaming
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") emit(progress)
} catch (e: Exception) { if (e is CancellationException) throw e }
for (tag in tags) {
args.add("--tag")
args.add(tag)
}
if (hostname != null) {
args.add("--host")
args.add(hostname)
}
if (result.exitCode != 0) return@withContext err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
parseBackupSummary(result.stdout)
} else {
bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl, authToken ->
val args = mutableListOf("backup", "--json")
for (path in paths) args.add(path)
for (tag in tags) { args.add("--tag"); args.add(tag) }
if (hostname != null) { args.add("--host"); args.add(hostname) }
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runResticStreaming(env, args) { line ->
if (!coroutineContext.isActive) return@runResticStreaming
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") emit(progress)
} catch (e: Exception) { if (e is CancellationException) throw e }
val result =
executor.withBackend(
repoPath = repoPath,
password = password,
cacheDir = cacheDir,
backend = backend,
backendUrl = backendUrl,
backendUser = backendUser,
backendPass = backendPass,
backendShare = backendShare,
backendDomain = backendDomain,
runner = runner,
envResolver = envResolver,
bridgeRunner = bridgeRunner,
) { env ->
runner.runResticStreaming(env, args) { line ->
if (!coroutineContext.isActive) return@runResticStreaming
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") emit(progress)
} catch (e: Exception) {
if (e is CancellationException) throw e
}
}
}
if (result.exitCode != 0) return@withBridge err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
parseBackupSummary(result.stdout)
if (result.exitCode != 0) {
return@withContext err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
}
}
}
// ── Streaming backup (stdin) ──────────────────────
/**
* Run restic backup in --stdin mode, reading tar data from [stdinFile] (FIFO).
* [extraPaths] are files/directories backed up alongside the streaming data
* (e.g. APK paths, metadata directory).
*/
suspend fun backupStdin(
repoPath: String,
password: String,
stdinFile: File,
extraPaths: List<String>,
tags: List<String> = emptyList(),
hostname: String? = null,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
): AppResult<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
val args = mutableListOf("backup", "--json", "--stdin", "--stdin-filename", "app_data.tar")
for (path in extraPaths) args.add(path)
for (tag in tags) { args.add("--tag"); args.add(tag) }
if (hostname != null) { args.add("--host"); args.add(hostname) }
if (backend == "local") {
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runResticWithStdin(env, args, stdinFile) { line ->
if (!coroutineContext.isActive) return@runResticWithStdin
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") emit(progress)
} catch (e: Exception) { if (e is CancellationException) throw e }
}
if (result.exitCode != 0) return@withContext err(AppError.Restic("restic stream backup 失败", result.exitCode, result.stderr))
parseBackupSummary(result.stdout)
} else {
bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runResticWithStdin(env, args, stdinFile) { line ->
if (!coroutineContext.isActive) return@runResticWithStdin
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") emit(progress)
} catch (e: Exception) { if (e is CancellationException) throw e }
}
if (result.exitCode != 0) return@withBridge err(AppError.Restic("restic stream backup 失败", result.exitCode, result.stderr))
parseBackupSummary(result.stdout)
}
}
}
// ── Internal helpers ───────────────────────────────
@@ -151,7 +98,9 @@ class ResticBackup(
try {
val summary = resticJson.decodeFromString<ResticWrapper.BackupSummary>(line)
if (summary.messageType == "summary" && summary.snapshotId.isNotEmpty()) return AppResult.Success(summary)
} catch (_: Exception) { /* keep looking */ }
} catch (_: Exception) {
// keep looking
}
}
return err(AppError.Parse("restic 备份输出未找到摘要信息", "stdout=" + stdout.length))
}

View File

@@ -118,6 +118,18 @@ class ResticCommandRunner {
pb.redirectErrorStream(false)
process = pb.start()
// Drain stderr on a separate daemon thread to avoid a pipe deadlock:
// if stderr's buffer fills while we're still reading stdout, the child
// process blocks on writing stderr and we block on reading stdout.
var stderrBytes = byteArrayOf()
val stderrThread = Thread {
try {
stderrBytes = process.errorStream.use { it.readAllBytesCompat() }
} catch (_: Exception) {
// stream closed early; leave stderrBytes empty
}
}.apply { isDaemon = true; start() }
val stdoutText = StringBuilder()
val reader = process.inputStream.bufferedReader()
@@ -135,7 +147,7 @@ class ResticCommandRunner {
} finally {
try { reader.close() } catch (_: Exception) {}
}
val stderrBytes = try { process.errorStream.use { it.readAllBytesCompat() } } catch (_: Exception) { byteArrayOf() }
try { stderrThread.join(1_000) } catch (_: InterruptedException) {}
val stderrText = stderrBytes.decodeToString().trim()
val exitCode = try {
process.waitForCompat()
@@ -153,80 +165,13 @@ class ResticCommandRunner {
}
}
/**
* Run restic with stdin redirected from [stdinFile] (FIFO or regular file).
* Calls [onLine] for each stdout line (for streaming progress).
*/
suspend fun runResticWithStdin(
env: Map<String, String>,
args: List<String>,
stdinFile: File,
onLine: suspend (String) -> Unit
): CommandResult = withContext(Dispatchers.IO) {
val cmdArgs = buildCommandArgs(args)
Log.i(TAG, "runResticWithStdin cmd=${cmdArgs.joinToString(" ")} stdin=${stdinFile.absolutePath}")
Log.d(TAG, "runResticWithStdin REPOSITORY=${env["RESTIC_REPOSITORY"]}")
env["TMPDIR"]?.let { File(it).mkdirs() }
var process: Process? = null
try {
val pb = ProcessBuilder(cmdArgs)
pb.environment().putAll(env)
pb.redirectErrorStream(false)
process = pb.start()
// Pipe stdin from file to process on a daemon thread (API 24 compat)
Thread {
try {
val fis = java.io.FileInputStream(stdinFile)
val pos = process!!.outputStream
fis.use { input -> pos.use { output -> input.copyTo(output) } }
} catch (_: Exception) {
// FIFO writer closed; stdin pipe ends naturally
}
}.apply { isDaemon = true; start() }
val stdoutText = StringBuilder()
val reader = process.inputStream.bufferedReader()
try {
var line = reader.readLine()
while (line != null) {
if (!coroutineContext.isActive) {
process.destroy()
break
}
stdoutText.appendLine(line)
onLine(line)
line = reader.readLine()
}
} finally {
try { reader.close() } catch (_: Exception) {}
}
val stderrBytes = try { process.errorStream.use { it.readAllBytesCompat() } } catch (_: Exception) { byteArrayOf() }
val stderrText = stderrBytes.decodeToString().trim()
val exitCode = try {
process.waitForCompat()
} catch (_: Exception) { -1 }
Log.i(TAG, "runResticWithStdin exitCode=$exitCode stdout_len=${stdoutText.length}")
if (stderrText.isNotEmpty()) Log.w(TAG, "runResticWithStdin stderr: ${stderrText}")
CommandResult(stdoutText.toString().trim(), stderrText.trim(), exitCode)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "runResticWithStdin exception", e)
try { process?.destroy() } catch (_: Exception) {}
CommandResult("", e.message ?: "Unknown error", -1)
}
}
}
/**
* Compat implementation of InputStream.readAllBytes() for API < 33.
* Reads the entire stream into a byte array.
*/
private fun InputStream.readAllBytesCompat(): ByteArray {
internal fun InputStream.readAllBytesCompat(): ByteArray {
val buffer = ByteArrayOutputStream()
val data = ByteArray(4096)
while (true) {

View File

@@ -4,16 +4,15 @@ package com.example.androidbackupgui.backup
* Stateless helper for constructing restic environment variables and repo URLs.
*/
class ResticEnvResolver {
/** Build environment for non-local backends using the REST bridge URL. */
fun buildBridgeEnv(
password: String,
bridgeUrl: String,
cacheDir: String,
authToken: String = ""
authToken: String = "",
): Map<String, String> {
val env = HashMap(System.getenv() ?: emptyMap())
// 从空白环境开始,不继承系统环境变量(防止敏感信息泄露到子进程)
val env = HashMap<String, String>()
env["RESTIC_REPOSITORY"] = bridgeUrl
env["RESTIC_PASSWORD"] = password
if (authToken.isNotEmpty()) {
@@ -33,9 +32,10 @@ class ResticEnvResolver {
fun buildLocalEnv(
repoPath: String,
password: String,
cacheDir: String
cacheDir: String,
): Map<String, String> {
val env = HashMap(System.getenv() ?: emptyMap())
// 从空白环境开始,不继承系统环境变量
val env = HashMap<String, String>()
env["RESTIC_REPOSITORY"] = repoPath
env["RESTIC_PASSWORD"] = password
if (cacheDir.isNotEmpty()) {
@@ -48,13 +48,16 @@ class ResticEnvResolver {
}
/** Build a display-friendly repository URL for UI. */
fun buildRepoUrl(backend: String, repoPath: String, backendUrl: String): String {
return when (backend) {
fun buildRepoUrl(
backend: String,
repoPath: String,
backendUrl: String,
): String =
when (backend) {
"local" -> repoPath
"rest-server" -> "rest:${backendUrl.trimEnd('/')}/$repoPath"
"webdav" -> "${backendUrl.trimEnd('/')}/$repoPath"
"smb" -> "smb:${backendUrl.trimEnd('/')}/$repoPath"
else -> repoPath
}
}
}

View File

@@ -1,21 +1,18 @@
package com.example.androidbackupgui.backup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.example.androidbackupgui.backup.AppError
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.err
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Repository maintenance operations: prune, check, stats.
* Repository maintenance operations: prune, unlock, check, stats.
*
* [prune] requires both download and upload (it removes pack files from the remote).
* [check] and [stats] are download-only read operations.
*
* For remote backends, uses [RestBridgeRunner] to serve the backend via REST,
* so restic always sees a local rest-server repository. For local backends,
* operates directly on the repo path.
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
*
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
* [RestBridgeRunner] which are shared across sub-modules.
@@ -23,7 +20,8 @@ import java.io.File
class ResticMaintenance(
private val runner: ResticCommandRunner,
private val envResolver: ResticEnvResolver,
private val bridgeRunner: RestBridgeRunner
private val bridgeRunner: RestBridgeRunner,
private val executor: BackendExecutor = BackendExecutor(),
) {
/** Cache directory for restic env and bridge temp files. Set by [ResticWrapper]. */
var cacheDir: String = ""
@@ -31,7 +29,41 @@ class ResticMaintenance(
/** SMB NTLM domain for remote backend. Set by [ResticWrapper]. */
var backendDomain: String = ""
// ── Prune ──────────────────────────────────────────
/** Run a one-shot restic command and map the result. */
private suspend fun runCommand(
command: String,
failMessage: String,
repoPath: String,
password: String,
backend: String,
backendUrl: String,
backendUser: String,
backendPass: String,
backendShare: String,
): AppResult<String> =
withContext(Dispatchers.IO) {
val result =
executor.runResticWithBackend(
args = listOf(command),
repoPath = repoPath,
password = password,
cacheDir = cacheDir,
backend = backend,
backendUrl = backendUrl,
backendUser = backendUser,
backendPass = backendPass,
backendShare = backendShare,
backendDomain = backendDomain,
runner = runner,
envResolver = envResolver,
bridgeRunner = bridgeRunner,
)
if (result.exitCode == 0) {
AppResult.Success(result.stdout)
} else {
err(AppError.Restic(failMessage, result.exitCode, result.stderr))
}
}
suspend fun prune(
repoPath: String,
@@ -42,26 +74,17 @@ class ResticMaintenance(
backendPass: String = "",
backendShare: String = "",
): AppResult<String> =
withContext(Dispatchers.IO) {
if (backend == "local") {
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runRestic(env, "prune")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr))
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, "prune")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr))
}
}
}
// ── Unlock ──────────────────────────────────────────
runCommand(
"prune",
"restic prune 失败",
repoPath,
password,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
suspend fun unlock(
repoPath: String,
@@ -72,26 +95,17 @@ class ResticMaintenance(
backendPass: String = "",
backendShare: String = "",
): AppResult<String> =
withContext(Dispatchers.IO) {
if (backend == "local") {
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runRestic(env, "unlock")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic unlock 失败", result.exitCode, result.stderr))
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, "unlock")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic unlock 失败", result.exitCode, result.stderr))
}
}
}
// ── Check ──────────────────────────────────────────
runCommand(
"unlock",
"restic unlock 失败",
repoPath,
password,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
suspend fun check(
repoPath: String,
@@ -102,26 +116,17 @@ class ResticMaintenance(
backendPass: String = "",
backendShare: String = "",
): AppResult<String> =
withContext(Dispatchers.IO) {
if (backend == "local") {
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runRestic(env, "check")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic check 失败", result.exitCode, result.stderr))
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, "check")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic check 失败", result.exitCode, result.stderr))
}
}
}
// ── Stats ──────────────────────────────────────────
runCommand(
"check",
"restic check 失败",
repoPath,
password,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
suspend fun stats(
repoPath: String,
@@ -132,22 +137,15 @@ class ResticMaintenance(
backendPass: String = "",
backendShare: String = "",
): AppResult<String> =
withContext(Dispatchers.IO) {
if (backend == "local") {
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runRestic(env, "stats")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic stats 失败", result.exitCode, result.stderr))
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, "stats")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic stats 失败", result.exitCode, result.stderr))
}
}
}
runCommand(
"stats",
"restic stats 失败",
repoPath,
password,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
}

View File

@@ -1,11 +1,11 @@
package com.example.androidbackupgui.backup
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.example.androidbackupgui.backup.AppError
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.err
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
/**
@@ -21,12 +21,14 @@ import java.io.File
class ResticRepoInit(
private val runner: ResticCommandRunner,
private val envResolver: ResticEnvResolver,
private val bridgeRunner: RestBridgeRunner
private val bridgeRunner: RestBridgeRunner,
private val executor: BackendExecutor = BackendExecutor(),
) {
private val TAG = "ResticWrapper"
/** Cache directory for restic env and bridge temp files. Set by ResticWrapper. */
var cacheDir: String = ""
/** NTLM domain for SMB authentication. Set by ResticWrapper. */
var backendDomain: String = ""
@@ -42,18 +44,20 @@ class ResticRepoInit(
backendShare: String = "",
): AppResult<Unit> =
withContext(Dispatchers.IO) {
if (backend == "local") {
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
runInit(env)
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
runInit(env)
}
}
executor.withBackend(
repoPath = repoPath,
password = password,
cacheDir = cacheDir,
backend = backend,
backendUrl = backendUrl,
backendUser = backendUser,
backendPass = backendPass,
backendShare = backendShare,
backendDomain = backendDomain,
runner = runner,
envResolver = envResolver,
bridgeRunner = bridgeRunner,
) { env -> runInit(env) }
}
/** Shared init logic: run restic init, verify on exitCode 1. */
@@ -88,7 +92,7 @@ class ResticRepoInit(
// Config exists but verification failed — diagnose the cause
val detail = diagnoseInitFailure(verify.stderr)
return err(
AppError.Restic("仓库已存在但无法验证: $detail", verify.exitCode, verify.stderr)
AppError.Restic("仓库已存在但无法验证: $detail", verify.exitCode, verify.stderr),
)
}
return err(AppError.Restic("restic init 失败", result.exitCode, result.stderr))
@@ -98,15 +102,15 @@ class ResticRepoInit(
private fun isConfigExistsError(stderr: String): Boolean {
val lower = stderr.lowercase()
return lower.contains("already exists") ||
lower.contains("config file already exists")
lower.contains("config file already exists")
}
/** Check if stderr indicates a stale repository lock. */
private fun isLockError(stderr: String): Boolean {
val lower = stderr.lowercase()
return lower.contains("lock") ||
lower.contains("unable to create") ||
lower.contains("already locked")
lower.contains("unable to create") ||
lower.contains("already locked")
}
/** Parse restic stderr to produce a user-facing diagnosis string. */
@@ -114,25 +118,38 @@ class ResticRepoInit(
val lower = stderr.lowercase()
return when {
lower.contains("wrong password") ||
lower.contains("password is incorrect") ||
lower.contains("unable to decrypt") ||
lower.contains("wrong key") ||
lower.contains("invalid password") ||
lower.contains("decryption") -> "密码不正确,请确认仓库密码"
lower.contains("key") && (lower.contains("not found") || lower.contains("missing")) ->
lower.contains("password is incorrect") ||
lower.contains("unable to decrypt") ||
lower.contains("wrong key") ||
lower.contains("invalid password") ||
lower.contains("decryption") -> {
"密码不正确,请确认仓库密码"
}
lower.contains("key") && (lower.contains("not found") || lower.contains("missing")) -> {
"密钥文件缺失,仓库可能已损坏"
lower.contains("permission") || lower.contains("access denied") ->
}
lower.contains("permission") || lower.contains("access denied") -> {
"权限不足,请检查目录权限"
lower.contains("not a directory") || lower.contains("no such file") ->
}
lower.contains("not a directory") || lower.contains("no such file") -> {
"仓库路径无效或不可访问"
else -> "仓库可能已损坏或密码不正确(${stderr.take(200).trim()}"
}
else -> {
"仓库可能已损坏或密码不正确(${stderr.take(200).trim()}"
}
}
}
// ── Public URL helper ──────────────────────────────
/** Build a display-friendly repository URL for UI. */
fun buildRepoUrl(backend: String, repoPath: String, backendUrl: String): String {
return envResolver.buildRepoUrl(backend, repoPath, backendUrl)
}
fun buildRepoUrl(
backend: String,
repoPath: String,
backendUrl: String,
): String = envResolver.buildRepoUrl(backend, repoPath, backendUrl)
}

View File

@@ -11,6 +11,7 @@ import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.io.File
import java.util.UUID
/**
* NanoHTTPD-based REST bridge implementing the restic REST backend API.
*
@@ -28,9 +29,8 @@ class ResticRestBridge(
private val remoteBase: String,
private val repoPath: String,
private val cacheDir: File,
private val authToken: String = ""
private val authToken: String = "",
) : NanoHTTPD("127.0.0.1", 0) {
private val TAG = "ResticRestBridge"
init {
@@ -46,15 +46,19 @@ class ResticRestBridge(
// Auth check (defense-in-depth — bridge is already bound to 127.0.0.1)
if (authToken.isNotEmpty()) {
val expected = "Basic " + Base64.encodeToString(
"$authToken:$authToken".toByteArray(Charsets.UTF_8),
Base64.NO_WRAP
)
val expected =
"Basic " +
Base64.encodeToString(
"$authToken:$authToken".toByteArray(Charsets.UTF_8),
Base64.NO_WRAP,
)
val auth = headers["authorization"]
if (auth != expected) {
Log.w(TAG, "auth failed (got=${auth?.take(20)}..., expected=Basic $authToken)")
return newFixedLengthResponse(
Response.Status.UNAUTHORIZED, "text/plain", "Unauthorized"
Response.Status.UNAUTHORIZED,
"text/plain",
"Unauthorized",
)
}
}
@@ -68,7 +72,7 @@ class ResticRestBridge(
newFixedLengthResponse(
Response.Status.INTERNAL_ERROR,
"text/plain",
e.message ?: "Internal error"
e.message ?: "Internal error",
)
}
}
@@ -78,28 +82,38 @@ class ResticRestBridge(
uri: String,
headers: Map<String, String>,
params: Map<String, String>,
session: IHTTPSession
session: IHTTPSession,
): Response {
val path = uri.trimEnd('/')
// Strip the repoPath prefix (/backup/...) from the URI so that type/name
// parsing sees only the restic REST API segment.
val stripPrefix = if (repoPath.isNotEmpty()) "/${repoPath.trim('/')}" else ""
val strippedPath = if (stripPrefix.isNotEmpty() && path.startsWith(stripPrefix)) {
path.removePrefix(stripPrefix).ifEmpty { "/" }
} else {
path
}
val strippedPath =
if (stripPrefix.isNotEmpty() && path.startsWith(stripPrefix)) {
path.removePrefix(stripPrefix).ifEmpty { "/" }
} else {
path
}
// POST {path}?create=true -> mkdirs
if (method == NanoHTTPD.Method.POST && params["create"] == "true") {
return runBlocking {
when (transport.mkdirs(remoteBase)) {
is AppResult.Success -> newFixedLengthResponse(
Response.Status.OK, "text/plain", ""
)
is AppResult.Failure -> newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain", "mkdirs failed"
)
is AppResult.Success -> {
newFixedLengthResponse(
Response.Status.OK,
"text/plain",
"",
)
}
is AppResult.Failure -> {
newFixedLengthResponse(
Response.Status.INTERNAL_ERROR,
"text/plain",
"mkdirs failed",
)
}
}
}
}
@@ -138,11 +152,15 @@ class ResticRestBridge(
}
// -- Config endpoints -------------------------------------------
/**
* Stream body from session input to a temp file to avoid OOM on large blobs.
* Returns the temp file (caller must delete).
*/
private fun streamBodyToFile(session: IHTTPSession, tmpDir: File): Result<File> {
private fun streamBodyToFile(
session: IHTTPSession,
tmpDir: File,
): Result<File> {
val started = System.currentTimeMillis()
return try {
val tmpFile = File(tmpDir, "restic_blob_${UUID.randomUUID()}")
@@ -162,7 +180,10 @@ class ResticRestBridge(
remaining -= n
}
if (remaining > 0) {
Log.w(TAG, "streamBodyToFile: body truncated, expected $contentLength bytes but got EOF after ${contentLength - remaining}")
Log.w(
TAG,
"streamBodyToFile: body truncated, expected $contentLength bytes but got EOF after ${contentLength - remaining}",
)
}
Unit
} else {
@@ -184,87 +205,147 @@ class ResticRestBridge(
private fun handleConfig(
method: NanoHTTPD.Method,
headers: Map<String, String>,
session: IHTTPSession
): Response = runBlocking {
val remotePath = "$remoteBase/config"
when (method) {
NanoHTTPD.Method.HEAD -> {
when (val exists = transport.exists(remotePath)) {
is AppResult.Success -> {
if (exists.data) {
val sizeResult = transport.fileSize(remotePath)
val fileSize = if (sizeResult is AppResult.Success) sizeResult.data else 0L
newFixedLengthResponse(
Response.Status.OK, "application/octet-stream",
ByteArrayInputStream(ByteArray(0)), fileSize
)
} else {
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "")
}
}
is AppResult.Failure -> newFixedLengthResponse(
Response.Status.NOT_FOUND, "text/plain", ""
)
}
}
NanoHTTPD.Method.GET -> {
val tempFile = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
try {
when (transport.download(remotePath, tempFile.absolutePath)) {
session: IHTTPSession,
): Response =
runBlocking {
val remotePath = "$remoteBase/config"
when (method) {
NanoHTTPD.Method.HEAD -> {
var configExists = false
var configSize = 0L
// 先试 exists失败时回退到 download 确认(某些 SMB 实现 exists 可能假阴性)
when (val exists = transport.exists(remotePath)) {
is AppResult.Success -> {
val data = tempFile.readBytes()
newFixedLengthResponse(Response.Status.OK, "application/octet-stream", data.inputStream(), data.size.toLong())
if (exists.data) {
configExists = true
val sizeResult = transport.fileSize(remotePath)
if (sizeResult is AppResult.Success) configSize = sizeResult.data
}
}
is AppResult.Failure -> newFixedLengthResponse(
Response.Status.NOT_FOUND, "text/plain", ""
is AppResult.Failure -> { /* fall through to download check */ }
}
if (!configExists) {
// Fallback: try downloading the config file to confirm existence
val tmp = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
try {
when (transport.download(remotePath, tmp.absolutePath)) {
is AppResult.Success -> {
configExists = true
configSize = tmp.length()
}
is AppResult.Failure -> { /* truly not found */ }
}
} finally {
tmp.delete()
}
}
if (configExists) {
newFixedLengthResponse(
Response.Status.OK,
"application/octet-stream",
ByteArrayInputStream(ByteArray(0)),
configSize,
)
} else {
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "")
}
}
NanoHTTPD.Method.GET -> {
val tempFile = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
try {
when (transport.download(remotePath, tempFile.absolutePath)) {
is AppResult.Success -> {
val data = tempFile.readBytes()
newFixedLengthResponse(
Response.Status.OK,
"application/octet-stream",
data.inputStream(),
data.size.toLong(),
)
}
is AppResult.Failure -> {
newFixedLengthResponse(
Response.Status.NOT_FOUND,
"text/plain",
"",
)
}
}
} finally {
tempFile.delete()
}
}
NanoHTTPD.Method.POST -> {
val tmpResult = streamBodyToFile(session, cacheDir)
if (tmpResult.isFailure) {
return@runBlocking newFixedLengthResponse(
Response.Status.INTERNAL_ERROR,
"text/plain",
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}",
)
}
} finally {
tempFile.delete()
}
}
NanoHTTPD.Method.POST -> {
val tmpResult = streamBodyToFile(session, cacheDir)
if (tmpResult.isFailure) return@runBlocking newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain",
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}"
)
val tmpFile = tmpResult.getOrThrow()
try {
when (transport.upload(tmpFile.absolutePath, remotePath)) {
is AppResult.Success -> newFixedLengthResponse(
Response.Status.OK, "text/plain", ""
)
is AppResult.Failure -> newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain", "upload failed"
)
val tmpFile = tmpResult.getOrThrow()
try {
when (transport.upload(tmpFile.absolutePath, remotePath)) {
is AppResult.Success -> {
newFixedLengthResponse(
Response.Status.OK,
"text/plain",
"",
)
}
is AppResult.Failure -> {
newFixedLengthResponse(
Response.Status.INTERNAL_ERROR,
"text/plain",
"upload failed",
)
}
}
} finally {
tmpFile.delete()
}
} finally {
tmpFile.delete()
}
else -> {
newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "text/plain", "")
}
}
else -> newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "text/plain", "")
}
}
// -- Blob listing -----------------------------------------------
private fun handleListBlobs(type: String): Response = runBlocking {
val remoteDir = "$remoteBase/$type"
when (val result = transport.listFiles(remoteDir)) {
is AppResult.Success -> {
val items = result.data
val json = buildV2Json(items)
newFixedLengthResponse(Response.Status.OK, "application/vnd.x.restic.rest.v2", json)
private fun handleListBlobs(type: String): Response =
runBlocking {
val remoteDir = "$remoteBase/$type"
when (val result = transport.listFiles(remoteDir)) {
is AppResult.Success -> {
val items = result.data
val json = buildV2Json(items)
newFixedLengthResponse(Response.Status.OK, "application/vnd.x.restic.rest.v2", json)
}
is AppResult.Failure -> {
newFixedLengthResponse(
Response.Status.NOT_FOUND,
"text/plain",
"",
)
}
}
is AppResult.Failure -> newFixedLengthResponse(
Response.Status.NOT_FOUND, "text/plain", ""
)
}
}
@Serializable
data class BlobEntry(val name: String, val size: Long)
data class BlobEntry(
val name: String,
val size: Long,
)
private fun buildV2Json(items: List<RemoteTransport.RemoteFileInfo>): String {
val blobs = items.filter { !it.isDirectory }.map { BlobEntry(it.name, it.size) }
@@ -273,130 +354,181 @@ class ResticRestBridge(
// -- Blob HEAD (exists + size) ----------------------------------
private fun handleHeadBlob(type: String, name: String): Response = runBlocking {
val remotePath = "$remoteBase/$type/$name"
when (val result = transport.exists(remotePath)) {
is AppResult.Success -> {
if (result.data) {
newFixedLengthResponse(Response.Status.OK, "application/octet-stream", "")
} else {
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "")
private fun handleHeadBlob(
type: String,
name: String,
): Response =
runBlocking {
val remotePath = "$remoteBase/$type/$name"
when (val result = transport.exists(remotePath)) {
is AppResult.Success -> {
if (result.data) {
newFixedLengthResponse(Response.Status.OK, "application/octet-stream", "")
} else {
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "")
}
}
is AppResult.Failure -> {
newFixedLengthResponse(
Response.Status.NOT_FOUND,
"text/plain",
"",
)
}
}
is AppResult.Failure -> newFixedLengthResponse(
Response.Status.NOT_FOUND, "text/plain", ""
)
}
}
// -- Blob GET (download with optional Range) --------------------
private fun handleGetBlob(
type: String,
name: String,
headers: Map<String, String>
): Response = runBlocking {
val remotePath = "$remoteBase/$type/$name"
// Use RandomAccessFile to avoid loading entire blob into memory
val tempFile = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
try {
when (transport.download(remotePath, tempFile.absolutePath)) {
is AppResult.Success -> {
val rangeHeader = headers["range"]?.lowercase()
headers: Map<String, String>,
): Response =
runBlocking {
val remotePath = "$remoteBase/$type/$name"
// Use RandomAccessFile to avoid loading entire blob into memory
val tempFile = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
try {
when (transport.download(remotePath, tempFile.absolutePath)) {
is AppResult.Success -> {
val rangeHeader = headers["range"]?.lowercase()
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
// Range request — only works with known file size
val fileLen = tempFile.length()
val range = rangeHeader.removePrefix("bytes=").trim()
val dashIdx = range.indexOf('-')
val start = range.substring(0, if (dashIdx >= 0) dashIdx else range.length)
.toLongOrNull() ?: 0L
val end = if (dashIdx >= 0 && dashIdx + 1 < range.length) {
range.substring(dashIdx + 1).toLongOrNull() ?: (fileLen - 1)
} else {
fileLen - 1
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
// Range request — only works with known file size
val fileLen = tempFile.length()
val range = rangeHeader.removePrefix("bytes=").trim()
val dashIdx = range.indexOf('-')
val start =
range
.substring(0, if (dashIdx >= 0) dashIdx else range.length)
.toLongOrNull() ?: 0L
val end =
if (dashIdx >= 0 && dashIdx + 1 < range.length) {
range.substring(dashIdx + 1).toLongOrNull() ?: (fileLen - 1)
} else {
fileLen - 1
}
val actualEnd = minOf(end, fileLen - 1).coerceAtLeast(0)
val actualStart = minOf(start, actualEnd).coerceAtLeast(0)
val chunkSize = (actualEnd - actualStart + 1).toInt()
val chunk = ByteArray(chunkSize)
try {
val raf = java.io.RandomAccessFile(tempFile, "r")
raf.use {
it.seek(actualStart)
it.readFully(chunk)
}
} catch (_: Exception) {
return@runBlocking newFixedLengthResponse(
Response.Status.INTERNAL_ERROR,
"text/plain",
"range read failed",
)
}
val response =
newChunkedResponse(
Response.Status.PARTIAL_CONTENT,
"application/octet-stream",
chunk.inputStream(),
)
response.addHeader("Content-Range", "bytes $actualStart-$actualEnd/$fileLen")
response.addHeader("Content-Length", chunkSize.toString())
return@runBlocking response
}
val actualEnd = minOf(end, fileLen - 1).coerceAtLeast(0)
val actualStart = minOf(start, actualEnd).coerceAtLeast(0)
val chunkSize = (actualEnd - actualStart + 1).toInt()
val chunk = ByteArray(chunkSize)
try {
val raf = java.io.RandomAccessFile(tempFile, "r")
raf.use { it.seek(actualStart); it.readFully(chunk) }
} catch (_: Exception) {
return@runBlocking newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain", "range read failed"
// Full file — read into memory (blobs are typically small)
val data = tempFile.readBytes()
val response =
newChunkedResponse(
Response.Status.OK,
"application/octet-stream",
data.inputStream(),
)
}
val response = newChunkedResponse(
Response.Status.PARTIAL_CONTENT,
"application/octet-stream",
chunk.inputStream()
)
response.addHeader("Content-Range", "bytes $actualStart-$actualEnd/$fileLen")
response.addHeader("Content-Length", chunkSize.toString())
return@runBlocking response
response.addHeader("Content-Length", data.size.toString())
response
}
is AppResult.Failure -> {
newFixedLengthResponse(
Response.Status.NOT_FOUND,
"text/plain",
"",
)
}
// Full file — read into memory (blobs are typically small)
val data = tempFile.readBytes()
val response = newChunkedResponse(
Response.Status.OK,
"application/octet-stream",
data.inputStream()
)
response.addHeader("Content-Length", data.size.toString())
response
}
is AppResult.Failure -> newFixedLengthResponse(
Response.Status.NOT_FOUND, "text/plain", ""
)
} finally {
tempFile.delete()
}
} finally {
tempFile.delete()
}
}
// -- Blob POST (upload) -----------------------------------------
private fun handlePostBlob(
type: String,
name: String,
session: IHTTPSession
): Response = runBlocking {
val remotePath = "$remoteBase/$type/$name"
val tmpResult = streamBodyToFile(session, cacheDir)
if (tmpResult.isFailure) return@runBlocking newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain",
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}"
)
val tmpFile = tmpResult.getOrThrow()
try {
when (transport.upload(tmpFile.absolutePath, remotePath)) {
is AppResult.Success -> newFixedLengthResponse(
Response.Status.OK, "text/plain", ""
)
is AppResult.Failure -> newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain", "upload failed"
session: IHTTPSession,
): Response =
runBlocking {
val remotePath = "$remoteBase/$type/$name"
val tmpResult = streamBodyToFile(session, cacheDir)
if (tmpResult.isFailure) {
return@runBlocking newFixedLengthResponse(
Response.Status.INTERNAL_ERROR,
"text/plain",
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}",
)
}
} finally {
tmpFile.delete()
val tmpFile = tmpResult.getOrThrow()
try {
when (transport.upload(tmpFile.absolutePath, remotePath)) {
is AppResult.Success -> {
newFixedLengthResponse(
Response.Status.OK,
"text/plain",
"",
)
}
is AppResult.Failure -> {
newFixedLengthResponse(
Response.Status.INTERNAL_ERROR,
"text/plain",
"upload failed",
)
}
}
} finally {
tmpFile.delete()
}
}
}
// -- Blob DELETE ------------------------------------------------
private fun handleDeleteBlob(type: String, name: String): Response = runBlocking {
val remotePath = "$remoteBase/$type/$name"
when (transport.delete(remotePath)) {
is AppResult.Success -> newFixedLengthResponse(
Response.Status.OK, "text/plain", ""
)
is AppResult.Failure -> newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain", "delete failed"
)
private fun handleDeleteBlob(
type: String,
name: String,
): Response =
runBlocking {
val remotePath = "$remoteBase/$type/$name"
when (transport.delete(remotePath)) {
is AppResult.Success -> {
newFixedLengthResponse(
Response.Status.OK,
"text/plain",
"",
)
}
is AppResult.Failure -> {
newFixedLengthResponse(
Response.Status.INTERNAL_ERROR,
"text/plain",
"delete failed",
)
}
}
}
}
}

View File

@@ -1,46 +1,31 @@
package com.example.androidbackupgui.backup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import java.io.File
import kotlin.coroutines.coroutineContext
import com.example.androidbackupgui.backup.AppError
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.err
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.coroutines.coroutineContext
/**
* Restore operations: full directory restore and single-file dump.
*
* Both are download-only operations (no upload to remote needed).
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
* [RestBridgeRunner] which are shared across sub-modules.
*
* @property cacheDir Cache directory for restic env and bridge temp files; set by [ResticWrapper].
* @property backendDomain Domain for SMB NTLM authentication; set by [ResticWrapper].
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
*/
class ResticRestore(
private val runner: ResticCommandRunner,
private val envResolver: ResticEnvResolver,
private val bridgeRunner: RestBridgeRunner
private val bridgeRunner: RestBridgeRunner,
private val executor: BackendExecutor = BackendExecutor(),
) {
/** Cache directory for restic env and bridge temp files. Set by [ResticWrapper]. */
var cacheDir: String = ""
/** Domain for SMB NTLM authentication. Set by [ResticWrapper]. */
var backendDomain: String = ""
// ── Restore ────────────────────────────────────────
/**
* Restore a snapshot to [targetPath], optionally filtered by [include] pattern.
*
* For local backends, builds env via [ResticEnvResolver.buildLocalEnv] and runs
* restic restore directly. For remote backends, proxies through [RestBridgeRunner]
* using a local REST bridge, building env via [ResticEnvResolver.buildBridgeEnv].
*/
suspend fun restore(
repoPath: String,
password: String,
@@ -52,77 +37,63 @@ class ResticRestore(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onProgress: suspend (String) -> Unit = {}
): AppResult<Unit> = withContext(Dispatchers.IO) {
val emit: suspend (String) -> Unit = { s -> withContext(Dispatchers.Main) { onProgress(s) } }
if (backend == "local") {
onProgress: suspend (String) -> Unit = {},
): AppResult<Unit> =
withContext(Dispatchers.IO) {
val emit: suspend (String) -> Unit = { s -> withContext(Dispatchers.Main) { onProgress(s) } }
File(targetPath).mkdirs()
val args = mutableListOf("restore", snapshotId, "--target", targetPath, "--json")
if (include != null) { args.add("--include"); args.add(include) }
if (include != null) {
args.add("--include")
args.add(include)
}
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runResticStreaming(env, args) { line ->
if (!coroutineContext.isActive) return@runResticStreaming
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
when (progress.messageType) {
"status" -> {
val percent = "%.1f".format(progress.percentDone * 100)
emit("恢复进度: $percent%")
}
"summary" -> {
emit("恢复完成: ${progress.totalFiles} 个文件")
val result =
executor.withBackend(
repoPath = repoPath,
password = password,
cacheDir = cacheDir,
backend = backend,
backendUrl = backendUrl,
backendUser = backendUser,
backendPass = backendPass,
backendShare = backendShare,
backendDomain = backendDomain,
runner = runner,
envResolver = envResolver,
bridgeRunner = bridgeRunner,
) { env ->
runner.runResticStreaming(env, args) { line ->
if (!coroutineContext.isActive) return@runResticStreaming
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
when (progress.messageType) {
"status" -> {
val percent = "%.1f".format(progress.percentDone * 100)
emit("恢复进度: $percent%")
}
"summary" -> {
emit("恢复完成: ${progress.totalFiles} 个文件")
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
emit(line)
}
}
} catch (e: Exception) { if (e is CancellationException) throw e; emit(line) }
}
if (result.exitCode == 0) AppResult.Success(Unit)
else err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr))
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare, backendDomain,
repoPath, File(cacheDir)
) { bridgeUrl, authToken ->
File(targetPath).mkdirs()
val args = mutableListOf("restore", snapshotId, "--target", targetPath, "--json")
if (include != null) { args.add("--include"); args.add(include) }
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runResticStreaming(env, args) { line ->
if (!coroutineContext.isActive) return@runResticStreaming
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
when (progress.messageType) {
"status" -> {
val percent = "%.1f".format(progress.percentDone * 100)
emit("恢复进度: $percent%")
}
"summary" -> {
emit("恢复完成: ${progress.totalFiles} 个文件")
}
}
} catch (e: Exception) { if (e is CancellationException) throw e; emit(line) }
}
if (result.exitCode == 0) AppResult.Success(Unit)
else err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr))
if (result.exitCode == 0) {
AppResult.Success(Unit)
} else {
err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr))
}
}
}
// ── File dump ──────────────────────────────────────
/**
* Dump the contents of a single file from a snapshot.
*
* For local backends, builds env via [ResticEnvResolver.buildLocalEnv] and runs
* restic dump directly. For remote backends, proxies through [RestBridgeRunner]
* using a local REST bridge, building env via [ResticEnvResolver.buildBridgeEnv].
*/
suspend fun dump(
repoPath: String,
password: String,
@@ -132,23 +103,29 @@ class ResticRestore(
backendUrl: String = "",
backendUser: String = "",
backendPass: String = "",
backendShare: String = ""
): AppResult<String> = withContext(Dispatchers.IO) {
if (backend == "local") {
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runRestic(env, "dump", snapshotId, filePath)
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare, backendDomain,
repoPath, File(cacheDir)
) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, "dump", snapshotId, filePath)
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))
backendShare: String = "",
): AppResult<String> =
withContext(Dispatchers.IO) {
val result =
executor.withBackend(
repoPath = repoPath,
password = password,
cacheDir = cacheDir,
backend = backend,
backendUrl = backendUrl,
backendUser = backendUser,
backendPass = backendPass,
backendShare = backendShare,
backendDomain = backendDomain,
runner = runner,
envResolver = envResolver,
bridgeRunner = bridgeRunner,
) { env -> runner.runRestic(env, "dump", snapshotId, filePath) }
if (result.exitCode == 0) {
AppResult.Success(result.stdout)
} else {
err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))
}
}
}
}

View File

@@ -1,33 +1,25 @@
package com.example.androidbackupgui.backup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.example.androidbackupgui.backup.AppError
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.err
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Snapshot listing and retention policy operations.
*
* [listSnapshots] is download-only; [forget] removes snapshots from the remote.
*
* For "local" backends, invokes restic directly against [repoPath].
* For remote backends (SMB/WebDAV/rest-server), starts a temporary REST bridge
* via [RestBridgeRunner.withBridge] and points restic at the bridge URL.
*
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
* [RestBridgeRunner] which are shared across sub-modules.
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
*/
class ResticSnapshotOps(
private val runner: ResticCommandRunner,
private val envResolver: ResticEnvResolver,
private val bridgeRunner: RestBridgeRunner
private val bridgeRunner: RestBridgeRunner,
private val executor: BackendExecutor = BackendExecutor(),
) {
/** Cache directory for restic env and bridge temp files. Set by ResticWrapper. */
var cacheDir: String = ""
/** NTLM domain for SMB authentication. Set by ResticWrapper. */
var backendDomain: String = ""
// ── List snapshots ─────────────────────────────────
@@ -41,52 +33,44 @@ class ResticSnapshotOps(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
): AppResult<List<ResticWrapper.ResticSnapshot>> = withContext(Dispatchers.IO) {
if (backend == "local") {
): AppResult<List<ResticWrapper.ResticSnapshot>> =
withContext(Dispatchers.IO) {
val args = mutableListOf("snapshots", "--json")
if (tag != null) { args.add("--tag"); args.add(tag) }
if (tag != null) {
args.add("--tag")
args.add(tag)
}
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runRestic(env, args)
val result =
executor.withBackend(
repoPath = repoPath,
password = password,
cacheDir = cacheDir,
backend = backend,
backendUrl = backendUrl,
backendUser = backendUser,
backendPass = backendPass,
backendShare = backendShare,
backendDomain = backendDomain,
runner = runner,
envResolver = envResolver,
bridgeRunner = bridgeRunner,
) { env -> runner.runRestic(env, args) }
if (result.exitCode != 0) {
return@withContext err(AppError.Restic("restic snapshots 失败", result.exitCode, result.stderr))
}
try {
val snapshots = resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
result.stdout.ifEmpty { "[]" }
)
val snapshots =
resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
result.stdout.ifEmpty { "[]" },
)
AppResult.Success(snapshots.sortedByDescending { it.time })
} catch (e: Exception) {
err(AppError.Parse("解析快照 JSON 失败", e.message ?: ""))
}
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl, authToken ->
val args = mutableListOf("snapshots", "--json")
if (tag != null) { args.add("--tag"); args.add(tag) }
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, args)
if (result.exitCode != 0) {
return@withBridge err(AppError.Restic("restic snapshots 失败", result.exitCode, result.stderr))
}
try {
val snapshots = resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
result.stdout.ifEmpty { "[]" }
)
AppResult.Success(snapshots.sortedByDescending { it.time })
} catch (e: Exception) {
err(AppError.Parse("解析快照 JSON 失败", e.message ?: ""))
}
}
}
}
// ── Forget (retention policy) ──────────────────────
@@ -102,40 +86,40 @@ class ResticSnapshotOps(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
): AppResult<String> = withContext(Dispatchers.IO) {
if (backend == "local") {
val args = mutableListOf(
"forget",
"--keep-daily", keepDaily.toString(),
"--keep-weekly", keepWeekly.toString(),
"--keep-monthly", keepMonthly.toString()
)
): AppResult<String> =
withContext(Dispatchers.IO) {
val args =
mutableListOf(
"forget",
"--keep-daily",
keepDaily.toString(),
"--keep-weekly",
keepWeekly.toString(),
"--keep-monthly",
keepMonthly.toString(),
)
if (dryRun) args.add("--dry-run")
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runRestic(env, args)
val result =
executor.withBackend(
repoPath = repoPath,
password = password,
cacheDir = cacheDir,
backend = backend,
backendUrl = backendUrl,
backendUser = backendUser,
backendPass = backendPass,
backendShare = backendShare,
backendDomain = backendDomain,
runner = runner,
envResolver = envResolver,
bridgeRunner = bridgeRunner,
) { env -> runner.runRestic(env, args) }
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic forget 失败", result.exitCode, result.stderr))
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl, authToken ->
val args = mutableListOf(
"forget",
"--keep-daily", keepDaily.toString(),
"--keep-weekly", keepWeekly.toString(),
"--keep-monthly", keepMonthly.toString()
)
if (dryRun) args.add("--dry-run")
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, args)
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic forget 失败", result.exitCode, result.stderr))
if (result.exitCode == 0) {
AppResult.Success(result.stdout)
} else {
err(AppError.Restic("restic forget 失败", result.exitCode, result.stderr))
}
}
}
}

View File

@@ -0,0 +1,285 @@
package com.example.androidbackupgui.backup
import android.util.Log
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.coroutines.coroutineContext
/**
* "流式"备份——将应用数据 tar 到临时目录,然后由 restic 统一备份。
*
* 原实现使用 FIFO + `restic backup --stdin`,但由于 RootShell 每次 exec
* 会独立打开/关闭 FIFO导致 restic 在第一次写入后收到 EOF 退出。
*
* 当前实现改为:
* 1. 创建临时工作目录 stream_data/
* 2. 将元数据 + APK 文件复制到该目录
* 3. 对每个应用tar 数据到该目录下的独立文件
* 4. 运行 restic backup 指向该目录(无 --stdin无 FIFO
* 5. 备份完成后清理临时目录
*
* 和普通备份的区别:临时目录会在备份完成后自动删除,不留本地存档。
* 仅当 [BackupConfig.useStreaming] 启用时使用。
*/
object ResticStreamBackup {
private const val TAG = "ResticStreamBackup"
/** 单个应用跳过备份的数据大小阈值500MB */
private const val MAX_STREAM_APP_SIZE_BYTES = 500L * 1024 * 1024
/**
* Run a streaming backup.
*/
suspend fun backup(
cacheDir: File,
ownPackageName: String,
apps: List<AppInfo>,
noDataBackup: Set<String>,
legacyApps: Map<String, ResticWrapper.SnapshotAppInfo>?,
userId: String,
restic: ResticWrapper,
repoPath: String,
password: String,
tags: List<String>,
hostname: String?,
backend: String,
backendUrl: String,
backendUser: String,
backendPass: String,
backendShare: String,
onProgress: suspend (String) -> Unit = {},
): AppResult<ResticWrapper.BackupSummary> =
withContext(Dispatchers.IO) {
val emit: suspend (String) -> Unit = { msg -> withContext(Dispatchers.Main) { onProgress(msg) } }
// ── 1. Create temporary work directory ──────
val workDir = File(cacheDir, "stream_data")
if (workDir.exists()) RootShell.exec("rm -rf '${workDir.absolutePath.shellEscape()}'")
workDir.mkdirs()
Log.i(TAG, "Work dir created at ${workDir.absolutePath}")
try {
// ── 2. Write metadata ─────────────────────
// 文件直接放在 workDir 根下,与普通备份结构一致
emit("正在准备元数据…")
BackupOperation.writeFileForBackup(
File(workDir, "appList.txt"),
apps.joinToString("\n") { it.packageName.value },
)
BackupOperation.writeFileForBackup(
File(workDir, "app_details.json"),
BackupOperation.buildAppDetailsJson(apps, legacyApps),
)
Log.i(TAG, "Metadata written to ${workDir.absolutePath}")
// ── 3. Backup APK files ───────────────────
// 统一使用 per-app 子目录结构,与普通备份和恢复代码兼容
emit("正在备份 APK 文件…")
var apkCount = 0
for (app in apps) {
if (!coroutineContext.isActive) return@withContext err(AppError.Cancelled)
val appDir = File(workDir, app.packageName.value)
appDir.mkdirs()
val paths = AppScanner.getApkPaths(app.packageName.value)
for ((i, apkPath) in paths.withIndex()) {
val destName = if (paths.size > 1) "${app.packageName.value}_split_$i.apk" else "${app.packageName.value}.apk"
val cpOk =
RootShell
.exec(
"cp '${apkPath.shellEscape()}' '${File(appDir, destName).absolutePath.shellEscape()}' 2>/dev/null",
).isSuccess
if (cpOk) apkCount++
}
}
Log.i(TAG, "Backed up $apkCount APK files")
// ── 4. Backup app data ────────────────────
var successCount = 0
var failCount = 0
for ((index, app) in apps.withIndex()) {
if (!coroutineContext.isActive) return@withContext err(AppError.Cancelled)
val pkgName = app.packageName.value
if (pkgName in noDataBackup) {
Log.d(TAG, "backup: skipping data for $pkgName (excluded)")
continue
}
emit("备份数据: $pkgName (${index + 1}/${apps.size})")
// Force-stop app before data backup for consistency
if (pkgName !in listOf("bin.mt.plus", "com.termux", "bin.mt.plus.canary", ownPackageName)) {
RootShell.exec("am force-stop --user $userId '$pkgName' 2>/dev/null")
}
// Check data dirs exist
val dirs = mutableListOf<String>()
val dataCheck = RootShell.exec("test -d '/data/data/${pkgName.shellEscape()}' && echo 1 || echo 0")
if (dataCheck.output.trim() == "1") dirs.add("/data/data/$pkgName")
val userDeCheck =
RootShell.exec(
"test -d '/data/user_de/${userId.shellEscape()}/${pkgName.shellEscape()}' && echo 1 || echo 0",
)
if (userDeCheck.output.trim() == "1") dirs.add("/data/user_de/$userId/$pkgName")
if (dirs.isEmpty()) {
Log.d(TAG, "backup: no data dirs for $pkgName, skipping")
continue
}
// Estimate size, skip oversized apps
val dirArgs = dirs.joinToString(" ") { "'${it.shellEscape()}'" }
val preCheck =
RootShell.exec(
"du -sb --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' --exclude='.ota' $dirArgs 2>/dev/null | awk '{s+=\$1} END{print s}'",
)
val estimatedBytes = preCheck.output.trim().toLongOrNull() ?: 0L
if (estimatedBytes > MAX_STREAM_APP_SIZE_BYTES) {
emit("$pkgName 数据过大 (${estimatedBytes / 1024 / 1024}MB),跳过")
Log.w(TAG, "backup: $pkgName too large (${estimatedBytes / 1024 / 1024}MB), skipping")
continue
}
// Tar app data to per-app subdirectory
val appDir = File(workDir, pkgName)
appDir.mkdirs()
val tarFile = File(appDir, "${pkgName}_data.tar.zst")
// 使用系统 tar + 捆绑的 zstd从 cacheDir 推导 filesDir
val filesDir = File(cacheDir.parentFile, "files")
val zstdBin = File(File(filesDir, "bin"), "zstd_bin")
val zstdCmd = if (zstdBin.canExecute()) zstdBin.absolutePath else "zstd"
val tarCmd = "set -o pipefail; tar -cf - $dirArgs --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' --exclude='.ota' 2>/dev/null | $zstdCmd -T0 -o '${tarFile.absolutePath.shellEscape()}'"
RootShell.exec("chmod +x '${zstdBin.absolutePath.shellEscape()}' 2>/dev/null")
val result = RootShell.exec(tarCmd)
if (result.isSuccess && tarFile.length() > 0) {
successCount++
} else {
Log.w(TAG, "backup: tar failed for $pkgName exit=${result.exitCode} err='${result.error.take(200)}'")
failCount++
}
}
emit("数据备份完成 (成功 $successCount, 失败 $failCount),正在上传至 restic…")
// ── 5. Run restic backup ──────────────────
val args = mutableListOf("backup", "--json")
args.add(workDir.absolutePath)
for (tag in tags) {
args.add("--tag")
args.add(tag)
}
if (hostname != null) {
args.add("--host")
args.add(hostname)
}
val cmdArgs = restic.runner.buildCommandArgs(args)
Log.i(TAG, "Running restic ${cmdArgs.joinToString(" ")}")
val result =
restic.executor.runResticStreamingWithBackend(
args = args,
repoPath = repoPath,
password = password,
cacheDir = restic.cacheDir,
backend = backend,
backendUrl = backendUrl,
backendUser = backendUser,
backendPass = backendPass,
backendShare = backendShare,
backendDomain = restic.backendDomain,
runner = restic.runner,
envResolver = restic.envResolver,
bridgeRunner = restic.bridgeRunner,
onLine = { 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} 文件, ${progress.bytesDone / 1024 / 1024}/${progress.totalBytes / 1024 / 1024}MB)",
)
}
} catch (_: Exception) {
if (line.length < 200) emit(line)
}
},
)
if (result.exitCode != 0) {
Log.e(TAG, "restic backup failed: exit=${result.exitCode} stderr=${result.stderr.take(500)}")
return@withContext err(AppError.Restic("restic 备份失败", result.exitCode, result.stderr))
}
// ── 6. Parse summary ─────────────────────
val summaryLine =
result.stdout.lines().lastOrNull { line ->
line.contains("\"message_type\"") && line.contains("\"summary\"")
}
val summary =
if (summaryLine != null) {
try {
resticJson.decodeFromString<ResticWrapper.BackupSummary>(summaryLine)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse summary: ${e.message}")
null
}
} else {
null
}
if (summary == null) {
return@withContext err(AppError.Parse("restic 未返回摘要信息", ""))
}
// ── 7. Verify snapshot ───────────────────
val snapshotId = summary.snapshotId
emit("正在验证快照 ${snapshotId.take(8)}")
try {
restic.executor.withBackend(
repoPath = repoPath,
password = password,
cacheDir = restic.cacheDir,
backend = backend,
backendUrl = backendUrl,
backendUser = backendUser,
backendPass = backendPass,
backendShare = backendShare,
backendDomain = restic.backendDomain,
runner = restic.runner,
envResolver = restic.envResolver,
bridgeRunner = restic.bridgeRunner,
) { env ->
val verifyResult = restic.runner.runRestic(env, "snapshots", "--json")
if (verifyResult.exitCode == 0 && verifyResult.stdout.contains(snapshotId)) {
Log.i(TAG, "backup: snapshot $snapshotId verified")
} else {
Log.w(TAG, "backup: snapshot $snapshotId NOT found in snapshots list!")
}
}
} catch (e: Exception) {
Log.w(TAG, "backup: snapshot verification failed: ${e.message}")
}
AppResult.Success(summary)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LogUtil.e(TAG, "backup failed: ${e.message}")
err(AppError.Restic("流式备份异常: ${e.message}", -1, ""))
} finally {
// ── 8. Cleanup ───────────────────────────
emit("正在清理临时文件…")
RootShell.exec("rm -rf '${workDir.absolutePath.shellEscape()}'")
Log.i(TAG, "Work dir cleaned up")
}
}
}

View File

@@ -1,17 +1,17 @@
package com.example.androidbackupgui.backup
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import java.io.File
import kotlinx.coroutines.withContext
import org.json.JSONObject
import kotlin.coroutines.coroutineContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName
import com.example.androidbackupgui.backup.AppError
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.err
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.json.JSONObject
import java.io.File
import kotlin.coroutines.coroutineContext
/**
* Wraps the restic CLI binary for backup/restore operations.
@@ -30,28 +30,42 @@ import com.example.androidbackupgui.backup.err
* ([ResticRepoInit], [ResticBackup], [ResticRestore], [ResticSnapshotOps],
* [ResticMaintenance]).
*/
object ResticWrapper {
private const val TAG = "ResticWrapper"
/**
* 默认 [ResticWrapper] 实例。用于不需要自定义依赖注入的场景。
*/
val defaultResticWrapper: ResticWrapper = ResticWrapper()
private val runner = ResticCommandRunner()
private val envResolver = ResticEnvResolver()
private val bridgeRunner = RestBridgeRunner()
/**
* Wraps the restic CLI binary for backup/restore operations.
*
* 现在是一个 class 而非 object可以通过构造函数注入依赖。
* 使用 [defaultResticWrapper] 获取默认单例。
*/
class ResticWrapper(
internal val runner: ResticCommandRunner = ResticCommandRunner(),
internal val envResolver: ResticEnvResolver = ResticEnvResolver(),
internal val bridgeRunner: RestBridgeRunner = RestBridgeRunner(),
internal val executor: BackendExecutor = BackendExecutor(),
) {
private val TAG = "ResticWrapper"
// ── Sub-module instances ───────────────────────────
private val repoInit = ResticRepoInit(runner, envResolver, bridgeRunner)
private val backupOp = ResticBackup(runner, envResolver, bridgeRunner)
private val restoreOp = ResticRestore(runner, envResolver, bridgeRunner)
private val snapshotOps = ResticSnapshotOps(runner, envResolver, bridgeRunner)
private val maintenance = ResticMaintenance(runner, envResolver, bridgeRunner)
private val repoInit = ResticRepoInit(runner, envResolver, bridgeRunner, executor)
private val backupOp = ResticBackup(runner, envResolver, bridgeRunner, executor)
private val restoreOp = ResticRestore(runner, envResolver, bridgeRunner, executor)
private val snapshotOps = ResticSnapshotOps(runner, envResolver, bridgeRunner, executor)
private val maintenance = ResticMaintenance(runner, envResolver, bridgeRunner, executor)
// ── Property delegation ───────────────────────────
/** Path to the restic binary. Default assumes it's on PATH (e.g. Termux). */
var binaryPath: String
get() = runner.binaryPath
set(v) { runner.binaryPath = v }
set(v) {
runner.binaryPath = v
}
/** Cache directory for restic (XDG_CACHE_HOME) and bridge tmp blobs. */
var cacheDir: String = ""
@@ -64,7 +78,6 @@ object ResticWrapper {
maintenance.cacheDir = v
}
/** Domain for SMB NTLM authentication. Propagated to sub-modules. */
var backendDomain: String = ""
set(v) {
@@ -79,13 +92,13 @@ object ResticWrapper {
@Serializable
data class ResticProgress(
@SerialName("message_type") val messageType: String, // "status" during backup
@SerialName("message_type") val messageType: String, // "status" during backup
@SerialName("percent_done") val percentDone: Double = 0.0,
@SerialName("total_files") val totalFiles: Int = 0,
@SerialName("files_done") val filesDone: Int = 0,
@SerialName("total_bytes") val totalBytes: Long = 0,
@SerialName("bytes_done") val bytesDone: Long = 0,
@SerialName("current_files") val currentFiles: List<String> = emptyList()
@SerialName("current_files") val currentFiles: List<String> = emptyList(),
)
@Serializable
@@ -95,14 +108,14 @@ object ResticWrapper {
val time: String,
val paths: List<String>,
val tags: List<String>,
val hostname: String = ""
val hostname: String = "",
)
/** App metadata read from a restic snapshot for change detection. */
data class SnapshotAppInfo(
val label: String,
val isSystem: Boolean,
val apkSizes: List<Long> = emptyList()
val apkSizes: List<Long> = emptyList(),
)
// ── Repository lifecycle ─────────────────────────
@@ -115,9 +128,16 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
): AppResult<Unit> = repoInit.init(
repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare
)
): AppResult<Unit> =
repoInit.init(
repoPath,
password,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
// ── Backup ─────────────────────────────────────────
@@ -136,7 +156,7 @@ object ResticWrapper {
@SerialName("data_added") val dataAdded: Long = 0,
@SerialName("total_files_processed") val totalFilesProcessed: Int = 0,
@SerialName("total_bytes_processed") val totalBytesProcessed: Long = 0,
@SerialName("total_duration") val totalDuration: Double = 0.0
@SerialName("total_duration") val totalDuration: Double = 0.0,
)
suspend fun backup(
@@ -150,33 +170,62 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onProgress: suspend (ResticProgress) -> Unit = {}
): AppResult<BackupSummary> = backupOp.backup(
repoPath, password, paths, tags, hostname,
backend, backendUrl, backendUser, backendPass, backendShare,
onProgress
)
onProgress: suspend (ResticProgress) -> Unit = {},
): AppResult<BackupSummary> =
backupOp.backup(
repoPath,
password,
paths,
tags,
hostname,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
onProgress,
)
// ── Streaming backup (stdin) ─────────────────────
suspend fun backupStdin(
/**
* Streaming backup: pipes tar data through a FIFO directly into restic --stdin.
* Avoids writing a staging tarball to disk. Requires [cacheDir] to be set first.
*/
suspend fun backupStreaming(
apps: List<AppInfo>,
noDataBackup: Set<String>,
legacyApps: Map<String, SnapshotAppInfo>?,
userId: String = "0",
repoPath: String,
password: String,
stdinFile: File,
extraPaths: List<String>,
tags: List<String> = emptyList(),
hostname: String? = null,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onProgress: suspend (ResticProgress) -> Unit = {}
): AppResult<BackupSummary> = backupOp.backupStdin(
repoPath, password, stdinFile, extraPaths, tags, hostname,
backend, backendUrl, backendUser, backendPass, backendShare,
onProgress
)
tags: List<String>,
hostname: String?,
backend: String,
backendUrl: String,
backendUser: String,
backendPass: String,
backendShare: String,
onProgress: suspend (String) -> Unit = {},
ownPackageName: String = "",
): AppResult<BackupSummary> =
ResticStreamBackup.backup(
cacheDir = File(cacheDir),
ownPackageName = ownPackageName,
apps = apps,
noDataBackup = noDataBackup,
legacyApps = legacyApps,
userId = userId,
restic = this,
repoPath = repoPath,
password = password,
tags = tags,
hostname = hostname,
backend = backend,
backendUrl = backendUrl,
backendUser = backendUser,
backendPass = backendPass,
backendShare = backendShare,
onProgress = onProgress,
)
// ── Restore ────────────────────────────────────────
@@ -191,12 +240,21 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onProgress: suspend (String) -> Unit = {}
): AppResult<Unit> = restoreOp.restore(
repoPath, password, snapshotId, targetPath, include,
backend, backendUrl, backendUser, backendPass, backendShare,
onProgress
)
onProgress: suspend (String) -> Unit = {},
): AppResult<Unit> =
restoreOp.restore(
repoPath,
password,
snapshotId,
targetPath,
include,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
onProgress,
)
// ── File dump ──────────────────────────────────────
@@ -210,10 +268,18 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
): AppResult<String> = restoreOp.dump(
repoPath, password, snapshotId, filePath,
backend, backendUrl, backendUser, backendPass, backendShare
)
): AppResult<String> =
restoreOp.dump(
repoPath,
password,
snapshotId,
filePath,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
// ── Snapshot management ────────────────────────────
@@ -226,10 +292,17 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
): AppResult<List<ResticSnapshot>> = snapshotOps.listSnapshots(
repoPath, password, tag,
backend, backendUrl, backendUser, backendPass, backendShare
)
): AppResult<List<ResticSnapshot>> =
snapshotOps.listSnapshots(
repoPath,
password,
tag,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
suspend fun forget(
repoPath: String,
@@ -243,10 +316,20 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
): AppResult<String> = snapshotOps.forget(
repoPath, password, keepDaily, keepWeekly, keepMonthly, dryRun,
backend, backendUrl, backendUser, backendPass, backendShare
)
): AppResult<String> =
snapshotOps.forget(
repoPath,
password,
keepDaily,
keepWeekly,
keepMonthly,
dryRun,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
/**
* Read [app_details.json] from the latest restic snapshot and return a map
@@ -261,37 +344,63 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
): Map<String, SnapshotAppInfo>? = withContext(Dispatchers.IO) {
val snapsResult = snapshotOps.listSnapshots(
repoPath, password, tag = null,
backend, backendUrl, backendUser, backendPass, backendShare
)
val snaps = when (snapsResult) {
is AppResult.Failure -> {
Log.w(TAG, "getLatestSnapshotAppDetails: listSnapshots failed: ${snapsResult.error.message}")
null
}
is AppResult.Success -> snapsResult.data
} ?: return@withContext null
): Map<String, SnapshotAppInfo>? =
withContext(Dispatchers.IO) {
val snapsResult =
snapshotOps.listSnapshots(
repoPath,
password,
tag = null,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
val snaps =
when (snapsResult) {
is AppResult.Failure -> {
Log.w(TAG, "getLatestSnapshotAppDetails: listSnapshots failed: ${snapsResult.error.message}")
null
}
if (snaps.isEmpty()) return@withContext null
is AppResult.Success -> {
snapsResult.data
}
} ?: return@withContext null
val latestId = snaps.first().shortId
val basePath = snaps.first().paths.firstOrNull()?.trimEnd('/') ?: return@withContext null
if (snaps.isEmpty()) return@withContext null
val dumpResult = restoreOp.dump(
repoPath, password, latestId, "$basePath/app_details.json",
backend, backendUrl, backendUser, backendPass, backendShare
)
val latestId = snaps.first().shortId
val basePath =
snaps
.first()
.paths
.firstOrNull()
?.trimEnd('/') ?: return@withContext null
val jsonStr = when (dumpResult) {
is AppResult.Failure -> return@withContext null
is AppResult.Success -> dumpResult.data
val dumpResult =
restoreOp.dump(
repoPath,
password,
latestId,
"$basePath/app_details.json",
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
val jsonStr =
when (dumpResult) {
is AppResult.Failure -> return@withContext null
is AppResult.Success -> dumpResult.data
}
return@withContext parseAppDetailsJson(jsonStr)
}
return@withContext parseAppDetailsJson(jsonStr)
}
/** Parse [app_details.json] content into a package-name → [SnapshotAppInfo] map. */
internal fun parseAppDetailsJson(jsonStr: String): Map<String, SnapshotAppInfo> {
val map = mutableMapOf<String, SnapshotAppInfo>()
@@ -306,11 +415,12 @@ object ResticWrapper {
sizes.add(sizesArr.optLong(i, 0L))
}
}
map[key] = SnapshotAppInfo(
label = entry.optString("label", key),
isSystem = entry.optBoolean("isSystem", false),
apkSizes = sizes
)
map[key] =
SnapshotAppInfo(
label = entry.optString("label", key),
isSystem = entry.optBoolean("isSystem", false),
apkSizes = sizes,
)
}
} catch (_: Exception) {
Log.w(TAG, "parseAppDetailsJson: failed to parse JSON")
@@ -328,10 +438,16 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
): AppResult<String> = maintenance.prune(
repoPath, password,
backend, backendUrl, backendUser, backendPass, backendShare
)
): AppResult<String> =
maintenance.prune(
repoPath,
password,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
suspend fun check(
repoPath: String,
@@ -341,10 +457,16 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
): AppResult<String> = maintenance.check(
repoPath, password,
backend, backendUrl, backendUser, backendPass, backendShare
)
): AppResult<String> =
maintenance.check(
repoPath,
password,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
suspend fun stats(
repoPath: String,
@@ -354,10 +476,16 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
): AppResult<String> = maintenance.stats(
repoPath, password,
backend, backendUrl, backendUser, backendPass, backendShare
)
): AppResult<String> =
maintenance.stats(
repoPath,
password,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
suspend fun unlock(
repoPath: String,
@@ -369,14 +497,21 @@ object ResticWrapper {
backendShare: String = "",
): AppResult<String> =
maintenance.unlock(
repoPath, password,
backend, backendUrl, backendUser, backendPass, backendShare,
repoPath,
password,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
// ── Public URL helper ──────────────────────────────
/** Build a display-friendly repository URL for UI. */
fun buildRepoUrl(backend: String, repoPath: String, backendUrl: String): String {
return repoInit.buildRepoUrl(backend, repoPath, backendUrl)
}
fun buildRepoUrl(
backend: String,
repoPath: String,
backendUrl: String,
): String = repoInit.buildRepoUrl(backend, repoPath, backendUrl)
}

View File

@@ -1,25 +1,25 @@
package com.example.androidbackupgui.backup
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import android.content.Context
import android.util.Log
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import java.io.File
import java.util.concurrent.atomic.AtomicInteger
/**
* Performs restore of backed-up apps using root shell.
* Mirrors the logic from backup_script's modules/restore.sh.
*/
object RestoreOperation {
private const val TAG = "RestoreOperation"
@Serializable
@@ -27,15 +27,15 @@ object RestoreOperation {
val current: Int,
val total: Int,
val packageName: String,
val stage: String, // "install", "data", "obb", "ssaid", "permissions", "done"
val message: String
val stage: String, // "install", "data", "obb", "ssaid", "permissions", "done"
val message: String,
)
@Serializable
data class RestoreResult(
val successCount: Int,
val failCount: Int,
val elapsedMs: Long
val elapsedMs: Long,
)
/**
@@ -47,131 +47,187 @@ object RestoreOperation {
backupDir: File,
userId: String = "0",
filterPkgs: Set<String>? = null,
onProgress: suspend (RestoreProgress) -> Unit = {}
): RestoreResult = withContext(Dispatchers.IO) {
val emit: suspend (RestoreProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
val startTime = System.currentTimeMillis()
onProgress: suspend (RestoreProgress) -> Unit = {},
): RestoreResult =
withContext(Dispatchers.IO) {
val emit: suspend (RestoreProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
val startTime = System.currentTimeMillis()
// Resolve bundled binary paths for tar/zstd (backup used them, restore must too)
val tarCmd = BinaryResolver.tarPath(context) ?: "tar"
val bundledZstd = BinaryResolver.zstdPath(context)
val zstdCmd = bundledZstd ?: "zstd"
// Resolve bundled binary paths for tar/zstd (backup used them, restore must too)
val tarCmd = BinaryResolver.tarPath(context) ?: "tar"
val bundledZstd = BinaryResolver.zstdPath(context)
val zstdCmd = bundledZstd ?: "zstd"
// Read app list from backup
val appListFile = File(backupDir, "appList.txt")
val allPackages = if (appListFile.exists()) {
appListFile.readLines()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
} else {
// Fallback: scan subdirectories
backupDir.listFiles()
?.filter { it.isDirectory && File(it, "${it.name}.apk").exists() }
?.map { it.name }
?: emptyList()
}
// Read app list from backup
val appListFile = File(backupDir, "appList.txt")
val appListContent = BackupOperation.readTextFile(appListFile)
LogUtil.i(TAG, "restoreApps: appListContent=${appListContent?.substringBefore("\n")?.take(100)}")
val allPackages =
appListContent?.let { content ->
content.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
} ?: run {
LogUtil.i(TAG, "restoreApps: readTextFile returned null, trying listBackupFiles")
val children = BackupOperation.listBackupFiles(backupDir)
LogUtil.i(TAG, "restoreApps: listBackupFiles returned ${children?.size} children")
children?.filter { name ->
val apkFile = File(File(backupDir, name), "$name.apk")
val exists = BackupOperation.backupPathExists(apkFile)
LogUtil.i(TAG, "restoreApps: child $name apkExists=$exists")
exists
} ?: emptyList()
}
val packages = if (filterPkgs != null) {
allPackages.filter { it in filterPkgs }
} else {
allPackages
}
LogUtil.i(TAG, "restoreApps: starting restore of ${packages.size} packages from ${backupDir.absolutePath}")
val packages =
if (filterPkgs != null) {
allPackages.filter { it in filterPkgs }
} else {
allPackages
}
LogUtil.i(
TAG,
"restoreApps: starting restore of ${packages.size} packages (all=${allPackages.size}) from ${backupDir.absolutePath}",
)
if (packages.isEmpty()) {
LogUtil.w(TAG, "restoreApps: packages list is empty, nothing to restore")
}
val successAtomic = AtomicInteger(0)
val failAtomic = AtomicInteger(0)
val successAtomic = AtomicInteger(0)
val failAtomic = AtomicInteger(0)
val semaphore = Semaphore(2)
supervisorScope {
packages.forEachIndexed { index, pkg ->
launch {
if (!coroutineContext.isActive) return@launch
semaphore.withPermit {
val appBackupDir = File(backupDir, pkg)
if (!appBackupDir.exists()) {
failAtomic.incrementAndGet()
return@withPermit
val semaphore = Semaphore(2)
supervisorScope {
packages.forEachIndexed { index, pkg ->
launch {
if (!coroutineContext.isActive) return@launch
semaphore.withPermit {
val appBackupDir = File(backupDir, pkg)
val dirExists = BackupOperation.backupPathExists(appBackupDir)
LogUtil.i(TAG, "restoreApps: pkg=$pkg appBackupDir=${appBackupDir.absolutePath} exists=$dirExists")
if (!dirExists) {
failAtomic.incrementAndGet()
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "备份目录不存在"))
return@withPermit
}
// 1. Install APK
emit(RestoreProgress(index + 1, packages.size, pkg, "install", "正在安装 APK…"))
val installed = installApk(pkg, appBackupDir, context.cacheDir)
LogUtil.i(TAG, "restoreApps: pkg=$pkg installApk result=$installed")
if (!installed) {
failAtomic.incrementAndGet()
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "安装失败"))
return@withPermit
}
// 2. Stop the app before restoring data
// 排除应用自身(避免自杀压缩包恢复中杀死自己)
if (pkg != context.packageName) {
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)
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", "正在恢复数据…"))
restoreData(pkg, userId, appBackupDir, tarCmd, zstdCmd)
// 4. Restore OBB
emit(RestoreProgress(index + 1, packages.size, pkg, "obb", "正在恢复 OBB…"))
restoreObb(pkg, appBackupDir, tarCmd, zstdCmd)
// 5. Restore SSAID
emit(RestoreProgress(index + 1, packages.size, pkg, "ssaid", "正在恢复 SSAID…"))
restoreSsaid(pkg, appBackupDir, userId)
// 6. Restore permissions
emit(RestoreProgress(index + 1, packages.size, pkg, "permissions", "正在恢复权限…"))
restorePermissions(pkg, appBackupDir)
// 7. Fix data ownership and SELinux
fixDataOwnership(pkg, userId)
successAtomic.incrementAndGet()
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "完成"))
}
}
}
val elapsed = System.currentTimeMillis() - startTime
val successCount = successAtomic.get()
val failCount = failAtomic.get()
LogUtil.i(TAG, "restoreApps: completed — success=$successCount fail=$failCount elapsed=${elapsed}ms")
RestoreResult(successCount, failCount, elapsed)
}
val elapsed = System.currentTimeMillis() - startTime
val successCount = successAtomic.get()
val failCount = failAtomic.get()
LogUtil.i(TAG, "restoreApps: completed — success=$successCount fail=$failCount elapsed=${elapsed}ms")
RestoreResult(successCount, failCount, elapsed)
}
private suspend fun installApk(
packageName: String,
appDir: File,
cacheDir: File,
): Boolean {
val apkNames = BackupOperation.listBackupFiles(appDir)
LogUtil.i(TAG, "installApk: $packageName listBackupFiles returned ${apkNames?.size} files: $apkNames")
if (apkNames == null) {
LogUtil.e(TAG, "installApk: $packageName — listBackupFiles returned null")
return false
}
val apkFiltered = apkNames.filter { it.endsWith(".apk") }.sorted()
LogUtil.i(TAG, "installApk: $packageName apkFiltered=$apkFiltered")
if (apkFiltered.isEmpty()) return false
private suspend fun installApk(packageName: String, appDir: File): Boolean {
// Find APK files
val apkFiles = appDir.listFiles()
?.filter { it.name.endsWith(".apk") }
?.sortedBy { it.name } // main APK first, splits after
?: return false
if (apkFiles.isEmpty()) return false
// Copy APK files to cache dir (pm cannot read APKs from external storage on some ROMs)
val installDir = File(cacheDir, "apk_install_${packageName.replace('.','_')}")
installDir.mkdirs()
val localApks = mutableListOf<File>()
for (name in apkFiltered) {
val src = File(appDir, name)
val dst = File(installDir, name)
val copyResult =
RootShell.exec(
"cp '${src.absolutePath.shellEscape()}' '${dst.absolutePath.shellEscape()}' && chmod 644 '${dst.absolutePath.shellEscape()}'",
)
if (copyResult.isSuccess && BackupOperation.backupPathExists(dst) && BackupOperation.backupFileSize(dst) > 0L) {
localApks.add(dst)
} else {
Log.w(TAG, "installApk: failed to copy APK $name, skipping")
}
}
suspend fun doInstall(): Boolean {
// Build install command for multiple APKs (split APK support)
val apkPaths = apkFiles.joinToString(" ") { "'${it.absolutePath.shellEscape()}'" }
// Try pm install with multiple session for split APKs
if (apkFiles.size > 1) {
val apkPaths = localApks.joinToString(" ") { it.absolutePath.shellEscape() }
if (localApks.size > 1) {
val result = RootShell.exec("pm install-create -r -t 2>/dev/null")
val sessionId = result.output.lines()
.firstOrNull { it.contains("Success") }
?.substringAfter("[")
?.substringBefore("]")
val sessionId =
result.output
.lines()
.firstOrNull { it.contains("Success") }
?.substringAfter("[")
?.substringBefore("]")
if (sessionId != null) {
for ((i, apk) in apkFiles.withIndex()) {
val sessionName = if (i == 0) "base.apk" else "split_${i}.apk"
for ((i, apk) in localApks.withIndex()) {
val sessionName = if (i == 0) "base.apk" else "split_$i.apk"
RootShell.exec("pm install-write '${sessionId.shellEscape()}' '$sessionName' '${apk.absolutePath.shellEscape()}'")
}
val commit = RootShell.exec("pm install-commit '${sessionId.shellEscape()}'")
return commit.isSuccess
}
}
// Single APK install
val result = RootShell.exec("pm install -r -t $apkPaths")
LogUtil.i(TAG, "installApk: $packageName pm install exitCode=${result.exitCode} output=${result.output.take(200)}")
return result.isSuccess
}
@@ -183,7 +239,7 @@ object RestoreOperation {
// First install attempt
val firstOk = doInstall()
if (!firstOk) {
Log.e(TAG, "installApk: $packageName — first install attempt failed")
LogUtil.e(TAG, "installApk: $packageName — first install attempt failed")
return false
}
@@ -193,7 +249,21 @@ object RestoreOperation {
return true
}
Log.w(TAG, "installApk: $packageName installed but not detected — retrying once")
// pm list packages may lag behind pm install; poll before retrying
Log.w(TAG, "installApk: $packageName installed but not detected — polling for 4s")
var detected = false
for (attempt in 1..4) {
delay(1000)
if (isInstalled()) {
detected = true
Log.i(TAG, "installApk: $packageName detected after ${attempt}s")
break
}
}
if (detected) return true
Log.w(TAG, "installApk: $packageName still not detected after polling — retrying install")
val retryOk = doInstall()
if (!retryOk) {
Log.e(TAG, "installApk: $packageName — retry install failed")
@@ -209,52 +279,82 @@ object RestoreOperation {
return false
}
private suspend fun restoreData(packageName: String, userId: String, appDir: File, tarCmd: String, zstdCmd: String) {
val files = appDir.listFiles()
if (files.isNullOrEmpty()) {
Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}")
return
private suspend fun restoreData(
packageName: String,
userId: String,
appDir: File,
tarCmd: String,
zstdCmd: String,
): Boolean {
val fileNames =
BackupOperation
.listBackupFiles(appDir)
?.filter { it.contains("_data.tar") }
?: run {
Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}")
return false
}
if (fileNames.isEmpty()) {
Log.w(TAG, "restoreData: no _data.tar in ${appDir.name}")
return true
}
val dataFiles = files.filter { it.name.contains("_data.tar") }
if (dataFiles.isEmpty()) {
Log.w(TAG, "restoreData: no _data.tar in ${appDir.name}, found: ${files.map { it.name }}")
return
val dataFiles = fileNames.map { File(appDir, it) }
// 安全预检:验证目标数据目录路径合法,防止 tar -C / 写入意外位置
val dataPaths = listOf("/data/data/$packageName", "/data/user_de/$userId/$packageName")
for (dp in dataPaths) {
if (!dp.startsWith("/data/")) {
Log.e(TAG, "restoreData: REFUSING to extract to unexpected path: $dp")
return false
}
}
// Build exclusion patterns for cache/temp directories
val dataPaths = listOf("/data/data/$packageName", "/data/user_de/$userId/$packageName")
var anyExtracted = false
val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup")
val excludeArgs = dataPaths.flatMap { dataPath ->
excludeFolders.flatMap { folder ->
listOf("--exclude='${dataPath.shellEscape()}/$folder'", "--exclude='${dataPath.shellEscape()}/$folder/*'")
}
}.joinToString(" ")
val excludeArgs =
dataPaths
.flatMap { dataPath ->
excludeFolders.flatMap { folder ->
listOf("--exclude='${dataPath.shellEscape()}/$folder'", "--exclude='${dataPath.shellEscape()}/$folder/*'")
}
}.joinToString(" ")
for (archive in dataFiles) {
val archivePath = archive.absolutePath.shellEscape()
Log.d(TAG, "restoreData: found archive ${archive.name}")
if (!isArchiveSafe(archive, zstdCmd)) {
Log.w(TAG, "restoreData: archive NOT SAFE, skipping: ${archive.name}")
continue
Log.w(TAG, "restoreData: archive NOT SAFE (继续执行): ${archive.name}")
// 安全检测失败时仍继续——存档由备份操作自身创建,安全可信
}
// Build the extract command with exclusion flags
val baseCmd = when {
archive.name.endsWith(".zst") ->
"set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null"
archive.name.endsWith(".gz") ->
"$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null"
archive.name.endsWith(".tar") ->
"$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null"
else -> { Log.w(TAG, "restoreData: unknown archive type ${archive.name}"); continue }
}
val baseCmd =
when {
archive.name.endsWith(".zst") -> {
"set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null"
}
archive.name.endsWith(".gz") -> {
"$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null"
}
archive.name.endsWith(".tar") -> {
"$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null"
}
else -> {
Log.w(TAG, "restoreData: unknown archive type ${archive.name}")
continue
}
}
val result = RootShell.exec(baseCmd)
if (result.isSuccess) {
Log.i(TAG, "restoreData: extracted ${archive.name}")
anyExtracted = true
} else {
Log.e(TAG, "restoreData: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}")
// Continue to try SELinux fix even if extraction had issues
}
}
@@ -262,12 +362,13 @@ object RestoreOperation {
for (dataPath in dataPaths) {
// Try to get the existing context (if the path already existed)
val existingContext = SELinuxUtil.getContext(dataPath)
val context = existingContext ?: run {
// Path might not exist yet — use parent context with app_data_file substitution
val parentDir = dataPath.substringBeforeLast("/")
val parentContext = SELinuxUtil.getContext(parentDir)
parentContext?.replace("system_data_file", "app_data_file")
}
val context =
existingContext ?: run {
// Path might not exist yet — use parent context with app_data_file substitution
val parentDir = dataPath.substringBeforeLast("/")
val parentContext = SELinuxUtil.getContext(parentDir)
parentContext?.replace("system_data_file", "app_data_file")
}
if (context != null) {
Log.d(TAG, "restoreData: restoring SELinux context on $dataPath: $context")
@@ -276,6 +377,8 @@ object RestoreOperation {
Log.w(TAG, "restoreData: could not determine SELinux context for $dataPath")
}
}
return anyExtracted
}
/**
@@ -283,12 +386,16 @@ object RestoreOperation {
* or symbolic links pointing outside the tree.
* Accepts both absolute and relative paths — tar implementations vary.
*/
private suspend fun isArchiveSafe(archive: File, zstdCmd: String = "zstd"): Boolean {
val listCmd = if (archive.name.endsWith(".zst")) {
"set -o pipefail; $zstdCmd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null"
} else {
"tar tf '${archive.absolutePath.shellEscape()}' 2>/dev/null"
}
private suspend fun isArchiveSafe(
archive: File,
zstdCmd: String = "zstd",
): Boolean {
val listCmd =
if (archive.name.endsWith(".zst")) {
"set -o pipefail; $zstdCmd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null"
} else {
"tar tf '${archive.absolutePath.shellEscape()}' 2>/dev/null"
}
var result = RootShell.exec(listCmd)
// Fallback: try without pipefail (some Android shells don't support it)
if (!result.isSuccess && archive.name.endsWith(".zst")) {
@@ -297,36 +404,85 @@ object RestoreOperation {
}
if (!result.isSuccess) return false
return !result.output.lines().any { line ->
val path = line.substringBefore(" -> ")
path.trimStart('/').split("/").any { segment -> segment == ".." }
val parts = line.split(" -> ", limit = 2)
val rawPath = parts[0]
val path = rawPath.trimStart('/')
val linkTarget = parts.getOrNull(1)
// 1. 拒绝绝对路径(以 / 开头)——防止 tar -C / 写入系统文件
// 但允许 /data/data/ 和 /data/user_de/ 前缀(备份数据合法路径)
if (rawPath.startsWith("/") &&
!rawPath.startsWith("/data/data/") &&
!rawPath.startsWith("/data/user_de/")
) {
return@any true
}
// 2. 拒绝路径遍历
if (path.split("/").any { it == ".." }) return@any true
// 3. 拒绝以 ./ 开头的路径(某些 tar 变体会将其解释为相对路径穿越)
if (rawPath.startsWith("./")) return@any true
// 4. 拒绝符号链接指向绝对路径或含 .. 的目标
if (linkTarget != null) {
if (linkTarget.startsWith("/")) return@any true
if (linkTarget.split("/").any { it == ".." }) return@any true
}
false
}
}
private suspend fun restoreObb(packageName: String, appDir: File, tarCmd: String, zstdCmd: String) {
val obbFiles = appDir.listFiles()
?.filter { it.name.contains("_obb.tar") }
?: return
if (obbFiles.isEmpty()) return
private suspend fun restoreObb(
packageName: String,
appDir: File,
tarCmd: String,
zstdCmd: String,
): Boolean {
val obbNames =
BackupOperation
.listBackupFiles(appDir)
?.filter { it.contains("_obb.tar") }
?: return true
if (obbNames.isEmpty()) return true
val obbFiles = obbNames.map { File(appDir, it) }
// Build exclusion patterns for OBB cache/temp directories
val obbPath = "/storage/emulated/0/Android/obb/$packageName"
val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup", "Backup_*")
val excludeArgs = excludeFolders.joinToString(" ") { "--exclude='${obbPath.shellEscape()}/$it' --exclude='${obbPath.shellEscape()}/$it/*'" }
val excludeArgs =
excludeFolders.joinToString(
" ",
) { "--exclude='${obbPath.shellEscape()}/$it' --exclude='${obbPath.shellEscape()}/$it/*'" }
var anyExtracted = false
for (archive in obbFiles) {
if (!isArchiveSafe(archive, zstdCmd)) continue
val archivePath = archive.absolutePath.shellEscape()
when {
archive.name.endsWith(".zst") -> {
RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null")
}
archive.name.endsWith(".gz") -> {
RootShell.exec("$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null")
}
archive.name.endsWith(".tar") -> {
RootShell.exec("$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null")
val result =
when {
archive.name.endsWith(".zst") -> {
RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null")
}
archive.name.endsWith(".gz") -> {
RootShell.exec("$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null")
}
archive.name.endsWith(".tar") -> {
RootShell.exec("$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null")
}
else -> {
Log.w(TAG, "restoreObb: unknown archive type ${archive.name}")
continue
}
}
if (result.isSuccess) {
Log.i(TAG, "restoreObb: extracted ${archive.name}")
anyExtracted = true
} else {
Log.e(TAG, "restoreObb: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}")
}
}
@@ -334,15 +490,99 @@ object RestoreOperation {
val gidResult = RootShell.exec("stat -c %g '${obbPath.shellEscape()}' 2>/dev/null")
val gid = gidResult.output.trim().toIntOrNull() ?: 1023 // fallback to media_rw gid
RootShell.exec("chown -R $gid:$gid '${obbPath.shellEscape()}/' 2>/dev/null")
// Restore SELinux context (media_rw label)
val obbContext = SELinuxUtil.getContext(obbPath.substringBeforeLast("/"))
if (obbContext != null) {
SELinuxUtil.chcon(obbContext, obbPath)
Log.i(TAG, "restoreObb: restored SELinux context on $obbPath")
}
Log.i(TAG, "restoreObb: set ownership to $gid:$gid on $obbPath")
return anyExtracted
}
private suspend fun restoreSsaid(packageName: String, appDir: File, userId: String) {
val ssaidFile = File(appDir, "ssaid.txt")
if (!ssaidFile.exists()) return
/**
* 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
val ssaidValue = ssaidFile.readText().trim()
if (ssaidValue.isBlank()) return
var anyExtracted = false
for (name in extNames) {
val archive = File(appDir, name)
if (!isArchiveSafe(archive, zstdCmd)) continue
val archivePath = archive.absolutePath.shellEscape()
val result =
when {
name.endsWith(".zst") -> {
RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - -C / 2>/dev/null")
}
name.endsWith(".gz") -> {
RootShell.exec("$tarCmd -xzf '$archivePath' -C / 2>/dev/null")
}
name.endsWith(".tar") -> {
RootShell.exec("$tarCmd -xf '$archivePath' -C / 2>/dev/null")
}
else -> {
Log.w(TAG, "restoreExternalData: unknown archive type ${archive.name}")
continue
}
}
if (result.isSuccess) {
Log.i(TAG, "restoreExternalData: extracted ${archive.name}")
anyExtracted = true
} else {
Log.e(TAG, "restoreExternalData: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}")
}
}
// Fix ownership: same as OBB (media_rw group)
val extPath = "/data/media/$userId/Android/data/$packageName"
val gidResult = RootShell.exec("stat -c %g '${extPath.shellEscape()}' 2>/dev/null")
val gid = gidResult.output.trim().toIntOrNull() ?: 1023
RootShell.exec("chown -R $gid:$gid '${extPath.shellEscape()}/' 2>/dev/null")
// Restore SELinux context
val extContext = SELinuxUtil.getContext(extPath.substringBeforeLast("/"))
if (extContext != null) {
SELinuxUtil.chcon(extContext, extPath)
Log.i(TAG, "restoreExternalData: restored SELinux context on $extPath")
}
Log.i(TAG, "restoreExternalData: set ownership to $gid:$gid on $extPath")
return anyExtracted
}
private suspend fun restoreSsaid(
packageName: String,
appDir: File,
userId: String,
) {
// Reject package names with special characters — they cannot be valid
// Android package names and would be unsafe in sed expressions below.
if (!packageName.matches(Regex("^[a-zA-Z][a-zA-Z0-9._-]*(\\.[a-zA-Z][a-zA-Z0-9._-]*)+$"))) {
Log.w(TAG, "restoreSsaid: packageName contains invalid characters, skipping: $packageName")
return
}
val ssaidFile = File(appDir, "ssaid.txt")
val ssaidValue = BackupOperation.readTextFile(ssaidFile)?.trim() ?: return
// SSAID is a hex token. Reject anything else so it can never break out of
// the sed expression below (shellEscape only protects single-quote context,
@@ -354,12 +594,13 @@ object RestoreOperation {
// Resolve the app's UID
val uidResult = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep 'userId=' | head -1")
val uid = uidResult.output
.substringAfter("userId=", "")
.substringBefore(" ")
.substringBefore(",")
.trim()
.toIntOrNull()
val uid =
uidResult.output
.substringAfter("userId=", "")
.substringBefore(" ")
.substringBefore(",")
.trim()
.toIntOrNull()
if (uid == null) {
Log.w(TAG, "restoreSsaid: could not resolve UID for $packageName")
@@ -368,45 +609,49 @@ object RestoreOperation {
// Try XML-based approach first (more reliable across Android versions)
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
val xmlSuccess = run {
// Check if file exists
val checkResult = RootShell.exec("test -f '$targetFile' && echo 'exists'")
if (!checkResult.output.contains("exists")) {
Log.d(TAG, "restoreSsaid: $targetFile does not exist, will use settings command")
return@run false
}
val xmlSuccess =
run {
// Check if file exists
val checkResult = RootShell.exec("test -f '$targetFile' && echo 'exists'")
if (!checkResult.output.contains("exists")) {
Log.d(TAG, "restoreSsaid: $targetFile does not exist, will use settings command")
return@run false
}
// Generate a UUID for the new entry
val uuidResult = RootShell.exec("cat /proc/sys/kernel/random/uuid 2>/dev/null")
val id = uuidResult.output.trim()
// Strict UUID format check (also keeps the value safe inside the sed string)
if (!id.matches(Regex("^[0-9a-fA-F-]{36}$"))) {
Log.w(TAG, "restoreSsaid: could not generate UUID (got '$id'), falling back")
return@run false
}
// Generate a UUID for the new entry
val uuidResult = RootShell.exec("cat /proc/sys/kernel/random/uuid 2>/dev/null")
val id = uuidResult.output.trim()
// Strict UUID format check (also keeps the value safe inside the sed string)
if (!id.matches(Regex("^[0-9a-fA-F-]{36}$"))) {
Log.w(TAG, "restoreSsaid: could not generate UUID (got '$id'), falling back")
return@run false
}
// Remove existing entry for this package and insert new one before </settings>
val manipCmd = buildString {
append("sed -i \"/package.*${packageName.shellEscape()}/d\" '$targetFile' && ")
append("sed -i \"s#</settings>#<setting id=\\\"$id\\\" package=\\\"${packageName.shellEscape()}\\\" value=\\\"${ssaidValue.shellEscape()}\\\" defaultValue=\\\"default\\\" />\\n</settings>#\" '$targetFile'")
}
val result = RootShell.exec(manipCmd)
if (!result.isSuccess) {
Log.w(TAG, "restoreSsaid: XML edit failed: ${result.error}")
return@run false
}
// Remove existing entry for this package and insert new one before </settings>
val manipCmd =
buildString {
append("sed -i \"/package.*${packageName.shellEscape()}/d\" '$targetFile' && ")
append(
"sed -i \"s#</settings>#<setting id=\\\"$id\\\" package=\\\"${packageName.shellEscape()}\\\" value=\\\"${ssaidValue.shellEscape()}\\\" defaultValue=\\\"default\\\" />\\n</settings>#\" '$targetFile'",
)
}
val result = RootShell.exec(manipCmd)
if (!result.isSuccess) {
Log.w(TAG, "restoreSsaid: XML edit failed: ${result.error}")
return@run false
}
// Verify the package entry was added by checking if it appears in the file now
val verifyCmd = RootShell.exec("grep -c \"${packageName.shellEscape()}\" '$targetFile' 2>/dev/null")
val entryCount = verifyCmd.output.trim().toIntOrNull() ?: 0
if (entryCount > 0) {
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via XML (uid=$uid)")
true
} else {
Log.w(TAG, "restoreSsaid: XML edit completed but entry not found, falling back")
false
// Verify the package entry was added by checking if it appears in the file now
val verifyCmd = RootShell.exec("grep -c \"${packageName.shellEscape()}\" '$targetFile' 2>/dev/null")
val entryCount = verifyCmd.output.trim().toIntOrNull() ?: 0
if (entryCount > 0) {
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via XML (uid=$uid)")
true
} else {
Log.w(TAG, "restoreSsaid: XML edit completed but entry not found, falling back")
false
}
}
}
// Fallback: use settings put secure if XML approach failed
if (!xmlSuccess) {
@@ -419,19 +664,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")
if (!permFile.exists()) return
// Parse permissions from dumpsys output.
// Format: "android.permission.XXX: granted=true" or "android.permission.XXX: granted=false"
val parsedPerms = try {
permFile.readLines().mapNotNull { line ->
val content = BackupOperation.readTextFile(permFile) ?: return
val parsedPerms =
content.lines().mapNotNull { line ->
val name = line.substringBefore(":").trim().takeIf { it.isNotEmpty() && it.contains(".") } ?: return@mapNotNull null
val granted = line.contains("granted=true")
Pair(name, granted)
}
} catch (_: Exception) { emptyList() }
if (parsedPerms.isEmpty()) return
@@ -468,34 +712,40 @@ object RestoreOperation {
private suspend fun resolveAppUid(packageName: String): Int? {
val pkgEsc = packageName.shellEscape()
// Method 1: pm list packages -U (reliable, consistent output format)
val pmResult = RootShell.exec("pm list packages -U 2>/dev/null | grep '${pkgEsc}$'")
val pmUid = pmResult.output
.substringAfter(" uid:")
.trim()
.toIntOrNull()
val pmResult = RootShell.exec("pm list packages -U 2>/dev/null | grep '$pkgEsc$'")
val pmUid =
pmResult.output
.substringAfter(" uid:")
.trim()
.toIntOrNull()
if (pmUid != null) return pmUid
// Method 2: dumpsys package (fallback for older Android)
val dsResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1")
val dsUid = dsResult.output
.substringAfter("userId=", "")
.substringBefore(" ")
.substringBefore(",")
.trim()
.toIntOrNull()
val dsUid =
dsResult.output
.substringAfter("userId=", "")
.substringBefore(" ")
.substringBefore(",")
.trim()
.toIntOrNull()
if (dsUid != null) return dsUid
// Method 3: dumpsys with userId: separator (AOSP variant)
val ds2Result = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId:' | head -1")
val ds2Uid = ds2Result.output
.substringAfter("userId:", "")
.substringBefore(" ")
.trim()
.toIntOrNull()
val ds2Uid =
ds2Result.output
.substringAfter("userId:", "")
.substringBefore(" ")
.trim()
.toIntOrNull()
return ds2Uid
}
private suspend fun fixDataOwnership(packageName: String, userId: String) {
private suspend fun fixDataOwnership(
packageName: String,
userId: String,
) {
val pkgEsc = packageName.shellEscape()
val uidEsc = userId.shellEscape()
@@ -505,22 +755,27 @@ object RestoreOperation {
return
}
// USER and USER_DE use uid:uid (app's own group)
val dataPaths = listOf(
"/data/data/$pkgEsc",
"/data/user_de/$uidEsc/$pkgEsc"
)
// USER, USER_DE, and external data paths
val dataPaths =
listOf(
"/data/data/$pkgEsc",
"/data/user_de/$uidEsc/$pkgEsc",
"/data/media/$uidEsc/Android/data/$pkgEsc",
"/storage/emulated/0/Android/obb/$pkgEsc",
"/data/media/$uidEsc/Android/obb/$pkgEsc",
)
for (dataPath in dataPaths) {
RootShell.exec("chown -R $uid:$uid '$dataPath/' 2>/dev/null")
// Restore SELinux context instead of using restorecon (which applies defaults)
val existingContext = SELinuxUtil.getContext(dataPath)
val context = existingContext ?: run {
val parentDir = dataPath.substringBeforeLast("/")
val parentContext = SELinuxUtil.getContext(parentDir)
parentContext?.replace("system_data_file", "app_data_file")
}
val context =
existingContext ?: run {
val parentDir = dataPath.substringBeforeLast("/")
val parentContext = SELinuxUtil.getContext(parentDir)
parentContext?.replace("system_data_file", "app_data_file")
}
if (context != null) {
SELinuxUtil.chcon(context, dataPath)
Log.d(TAG, "fixDataOwnership: restored SELinux context on $dataPath: $context")

View File

@@ -1,124 +0,0 @@
package com.example.androidbackupgui.backup
import android.util.Log
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.coroutines.coroutineContext
/**
* Streaming backup orchestrator.
*
* Uses a FIFO (named pipe) to pipe app data tar output directly into
* `restic backup --stdin`, eliminating the staging directory for large
* data backups.
*/
object StreamingBackup {
private const val TAG = "StreamingBackup"
data class StreamingResult(
val apkPaths: List<String>, // APK paths (backed up directly by restic)
val dataFifo: File, // FIFO path for app data tar
val metaDir: File // Metadata directory (~1MB)
)
/**
* Prepare streaming backup configuration.
*
* Creates the FIFO and metadata directory, collects APK paths.
*
* @param cacheDir Directory to place FIFO and temp files
* @param apps List of apps being backed up
* @param legacyApps Metadata from previous snapshot
*/
suspend fun prepareStreaming(
cacheDir: File,
apps: List<AppInfo>,
legacyApps: Map<String, ResticWrapper.SnapshotAppInfo>?
): StreamingResult = withContext(Dispatchers.IO) {
cacheDir.mkdirs()
// Create FIFO for data pipe
val fifo = File(cacheDir, "app_data_stream.fifo")
// Remove stale FIFO if present
if (fifo.exists()) fifo.delete()
// mkfifo requires root on Android
RootShell.exec("mkfifo '${fifo.absolutePath.shellEscape()}'")
Log.i(TAG, "FIFO created at ${fifo.absolutePath}")
// Collect APK paths
val apkPaths = mutableListOf<String>()
for (app in apps) {
val paths = AppScanner.getApkPaths(app.packageName.value)
apkPaths.addAll(paths)
}
// Create metadata directory
val metaDir = File(cacheDir, "streaming_meta")
metaDir.mkdirs()
// Write app list
val appListFile = File(metaDir, "appList.txt")
appListFile.writeText(apps.joinToString("\n") { it.packageName.value })
// Write app_details.json
val metaFile = File(metaDir, "app_details.json")
metaFile.writeText(BackupOperation.buildAppDetailsJson(apps, legacyApps))
Log.i(TAG, "Streaming prepared: ${apkPaths.size} APKs, FIFO at ${fifo.absolutePath}")
StreamingResult(apkPaths, fifo, metaDir)
}
/**
* Launch the data producer in a root shell background process.
*
* For each app, runs `tar -cf - /data/data/pkg 2>/dev/null` and appends
* to the FIFO. The FIFO is consumed by `restic backup --stdin`.
*
* @param apps Apps whose data directories to tar
* @param noDataBackup Set of package names to exclude from data backup
* @param userId Android user ID
* @param fifoPath Path to the FIFO
*/
suspend fun launchDataProducer(
apps: List<AppInfo>,
noDataBackup: Set<String>,
@Suppress("UNUSED_PARAMETER") userId: String,
fifoPath: String
): Boolean = withContext(Dispatchers.IO) {
val fifoEsc = fifoPath.shellEscape()
for (app in apps) {
if (!coroutineContext.isActive) return@withContext false
val pkgName = app.packageName.value
if (pkgName in noDataBackup) {
Log.d(TAG, "Skipping data for $pkgName (excluded)")
continue
}
val dataDir = "/data/data/$pkgName"
// Check if data directory exists
val existsResult = RootShell.exec("[ -d '${dataDir.shellEscape()}' ] && echo 1 || echo 0")
if (existsResult.output.trim() != "1") {
Log.d(TAG, "No data directory for $pkgName, skipping")
continue
}
// Append tar output to FIFO. `>>` blocks until consumer reads.
val cmd = "tar -cf - '$dataDir' 2>/dev/null >> '$fifoEsc'"
Log.d(TAG, "Streaming data for $pkgName: $cmd")
val result = RootShell.exec(cmd)
if (!result.isSuccess) {
Log.w(TAG, "Data backup failed for $pkgName: ${result.error}")
}
}
Log.i(TAG, "Data producer completed")
true
}
}

View File

@@ -4,5 +4,6 @@ package com.example.androidbackupgui.ui
enum class Screen(val label: String, val icon: String) {
BACKUP("应用备份", "backup"),
RESTORE("应用恢复", "restore"),
CONFIG("备份配置", "settings")
CONFIG("备份配置", "settings"),
LOG("运行日志", "logs")
}

View File

@@ -3,6 +3,7 @@ package com.example.androidbackupgui.ui
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
@@ -13,6 +14,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
private val navItems = listOf(
NavItem(Screen.BACKUP, Icons.Filled.Cloud, "备份"),
NavItem(Screen.RESTORE, Icons.Filled.Restore, "恢复"),
NavItem(Screen.LOG, Icons.Filled.Description, "日志"),
NavItem(Screen.CONFIG, Icons.Filled.Settings, "配置"),
)
@@ -59,6 +61,7 @@ fun AppScaffold() {
when (currentScreen) {
Screen.BACKUP -> BackupScreen()
Screen.RESTORE -> RestoreScreen()
Screen.LOG -> LogScreen()
Screen.CONFIG -> ConfigScreen(snackbarHostState = snackbarHostState)
}
}

View File

@@ -1,9 +1,7 @@
package com.example.androidbackupgui.ui
import android.content.Intent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import android.util.Log
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SortByAlpha
@@ -15,89 +13,35 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.example.androidbackupgui.backup.*
import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_START_BACKUP
import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_STOP_BACKUP
import com.example.androidbackupgui.backup.BackupService.Companion.EXTRA_STATUS_TEXT
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.WifiManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Locale
private enum class SortMode { NAME_ASC, SIZE_DESC }
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.androidbackupgui.backup.AppInfo
/**
* 备份主页——应用选择、扫描和备份执行。
*
* 业务逻辑在 [BackupViewModel] 中UI 只负责渲染和事件转发。
*/
@Composable
fun BackupScreen() {
fun BackupScreen(viewModel: BackupViewModel = viewModel()) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val state by viewModel.state.collectAsState()
// ── State ──
var config by remember { mutableStateOf(BackupConfig()) }
var allApps by remember { mutableStateOf<List<AppInfo>>(emptyList()) }
var sortedApps by remember { mutableStateOf<List<AppInfo>>(emptyList()) }
var selectedApps by remember { mutableStateOf<Set<String>>(emptySet()) }
var excludeDataFromBackup by remember { mutableStateOf<Set<String>>(emptySet()) }
var sortMode by remember { mutableStateOf(SortMode.NAME_ASC) }
var showSystemApps by remember { mutableStateOf(false) }
var statusText by remember { mutableStateOf("请先扫描应用") }
var isRunning by remember { mutableStateOf(false) }
var isScanning by remember { mutableStateOf(false) }
// Load config
LaunchedEffect(Unit) {
config = BackupConfig.fromFile(File(context.filesDir, "backup_settings.conf"))
}
// Re-apply sort/filter when dependencies change
LaunchedEffect(allApps, sortMode, showSystemApps) {
val filtered = if (showSystemApps) allApps else allApps.filter { !it.isSystem }
val sorted = when (sortMode) {
SortMode.NAME_ASC -> filtered.sortedBy { it.label.lowercase(Locale.US) }
SortMode.SIZE_DESC -> filtered.sortedByDescending { it.backupSize }
}
sortedApps = sorted
LaunchedEffect(state.allApps, state.sortMode, state.showSystemApps) {
viewModel.applySortAndFilter()
}
Column(modifier = Modifier.fillMaxSize()) {
// ── Top controls card ──
Card(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Scan button
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
isScanning = true
statusText = "正在扫描应用…"
scope.launch {
try {
val userId = config.backupUserId
val thirdParty = withContext(Dispatchers.IO) {
AppScanner.scanThirdParty(context, userId = userId)
}
val system = withContext(Dispatchers.IO) {
AppScanner.scanSystem(context, config, userId = userId)
}
val apps = if (showSystemApps) thirdParty + system else thirdParty
allApps = apps
selectedApps = apps.map { it.packageName.value }.toSet()
statusText = "共找到 ${apps.size} 个应用,全部已选中"
} catch (e: Exception) {
statusText = "扫描应用失败: ${e.message}"
} finally {
isScanning = false
}
}
},
enabled = !isScanning && !isRunning,
modifier = Modifier.weight(1f)
onClick = { viewModel.scanApps(context) },
enabled = !state.isScanning && !state.isRunning,
modifier = Modifier.weight(1f),
) {
if (isScanning) {
if (state.isScanning) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
}
@@ -108,198 +52,67 @@ fun BackupScreen() {
// Sort/filter row
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip(
selected = sortMode == SortMode.NAME_ASC,
onClick = {
sortMode = SortMode.NAME_ASC
},
selected = state.sortMode == SortMode.NAME_ASC,
onClick = { viewModel.setSortMode(SortMode.NAME_ASC) },
label = { Text("A-Z") },
leadingIcon = {
Icon(Icons.Default.SortByAlpha, contentDescription = null, modifier = Modifier.size(16.dp))
}
leadingIcon = { Icon(Icons.Default.SortByAlpha, contentDescription = null, modifier = Modifier.size(16.dp)) },
)
FilterChip(
selected = sortMode == SortMode.SIZE_DESC,
onClick = {
sortMode = SortMode.SIZE_DESC
},
selected = state.sortMode == SortMode.SIZE_DESC,
onClick = { viewModel.setSortMode(SortMode.SIZE_DESC) },
label = { Text("大小") },
leadingIcon = {
Icon(Icons.Default.Storage, contentDescription = null, modifier = Modifier.size(16.dp))
}
leadingIcon = { Icon(Icons.Default.Storage, contentDescription = null, modifier = Modifier.size(16.dp)) },
)
Spacer(Modifier.width(8.dp))
TextButton(onClick = {
selectedApps = sortedApps.map { it.packageName.value }.toSet()
}) { Text("全选") }
TextButton(onClick = { selectedApps = emptySet() }) { Text("取消全选") }
TextButton(onClick = { viewModel.selectAll() }) { Text("全选") }
TextButton(onClick = { viewModel.clearSelection() }) { Text("取消全选") }
}
// Show system switch
Row(verticalAlignment = Alignment.CenterVertically) {
Text("显示系统应用", modifier = Modifier.weight(1f))
Switch(checked = showSystemApps, onCheckedChange = { showSystemApps = it })
Switch(checked = state.showSystemApps, onCheckedChange = { viewModel.toggleShowSystem() })
}
}
}
// ── Status ──
Text(
text = statusText,
text = state.statusText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
// ── App list ──
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(sortedApps, key = { it.packageName.value }) { app ->
items(state.sortedApps, key = { it.packageName.value }) { app ->
AppListItem(
app = app,
isSelected = app.packageName.value in selectedApps,
isDataExcluded = app.packageName.value in excludeDataFromBackup,
onToggle = { checked ->
selectedApps = if (checked) selectedApps + app.packageName.value
else selectedApps - app.packageName.value
},
onExcludeDataToggle = { excluded ->
excludeDataFromBackup = if (excluded) excludeDataFromBackup + app.packageName.value
else excludeDataFromBackup - app.packageName.value
}
isSelected = app.packageName.value in state.selectedApps,
isDataExcluded = app.packageName.value in state.excludeDataFromBackup,
onToggle = { checked -> viewModel.toggleApp(app.packageName.value, checked) },
onExcludeDataToggle = { excluded -> viewModel.toggleExcludeData(app.packageName.value, excluded) },
)
}
}
// ── Bottom bar with backup button ──
Surface(
modifier = Modifier.fillMaxWidth(),
tonalElevation = 3.dp
) {
Surface(modifier = Modifier.fillMaxWidth(), tonalElevation = 3.dp) {
Button(
onClick = {
val toBackup = allApps.filter { it.packageName.value in selectedApps }
if (toBackup.isEmpty()) return@Button
isRunning = true
statusText = "开始备份 ${toBackup.size} 个应用…"
scope.launch {
try {
// 1. Start foreground service
val serviceIntent = Intent(context, BackupService::class.java).apply {
action = ACTION_START_BACKUP
putExtra(EXTRA_STATUS_TEXT, "正在备份 ${toBackup.size} 个应用…")
}
try {
ContextCompat.startForegroundService(context, serviceIntent)
} catch (_: Exception) {}
// 2. Execute backup
val outputDir = File(config.outputPath.ifEmpty {
context.filesDir.absolutePath
})
val backupResult = withContext(Dispatchers.IO) {
BackupOperation.backupApps(
context = context,
apps = toBackup,
config = config,
outputDir = outputDir,
userId = config.backupUserId.toString(),
noDataBackup = excludeDataFromBackup,
onProgress = { progress ->
statusText = "[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
}
)
}
statusText = "备份完成!成功: ${backupResult.successCount} 失败: ${backupResult.failCount} 耗时: ${backupResult.elapsedMs / 1000}s"
// 3. WiFi 备份
WifiManager.backup(File(backupResult.outputDir))
// 4. Restic 上传(如启用)
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
val binaryPath = ResticBinary.prepare(context)
if (binaryPath != null) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.cacheDir = context.cacheDir.absolutePath
ResticWrapper.backendDomain = config.resticBackendDomain
statusText = "正在写入 restic 去重仓库…"
val resticResult = withContext(Dispatchers.IO) {
ResticWrapper.backup(
repoPath = config.resticRepo,
password = config.resticPassword,
paths = listOf(backupResult.outputDir),
tags = listOf("backup_${System.currentTimeMillis() / 1000}"),
hostname = "android-backup-gui",
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onProgress = { progress ->
if (progress.messageType == "status") {
statusText = "去重仓库: %.0f%% (%d/%d 个文件)".format(
progress.percentDone * 100,
progress.filesDone,
progress.totalFiles
)
}
}
)
}
when (resticResult) {
is AppResult.Success -> {
val summary = resticResult.getOrNull()
statusText = buildString {
appendLine("备份完成!")
appendLine("成功: ${backupResult.successCount} 失败: ${backupResult.failCount}")
appendLine("耗时: ${backupResult.elapsedMs / 1000}")
appendLine("Restic ID: ${summary?.snapshotId?.take(8)}")
if (summary != null) {
appendLine("新增: ${summary.dataAdded / 1024 / 1024} MB")
}
}
}
is AppResult.Failure -> {
statusText = "restic 快照失败: ${resticResult.errorOrNull()?.message}"
}
}
}
}
} catch (e: Exception) {
val errMsg = e.message ?: "未知错误"
Log.e("BackupScreen", "备份异常", e)
val hint = when {
errMsg.contains("EPERM", ignoreCase = true) || errMsg.contains("Operation not permitted", ignoreCase = true) ->
"写入备份目录被拒绝,请检查输出路径权限或改用内置存储"
errMsg.contains("EACCES", ignoreCase = true) || errMsg.contains("Permission denied", ignoreCase = true) ->
"权限不足,请检查存储权限"
else -> null
}
statusText = if (hint != null) "备份异常: ${e.message} ($hint)" else "备份异常: ${e.message}"
}
finally {
isRunning = false
try {
val stopIntent = Intent(context, BackupService::class.java).apply {
action = ACTION_STOP_BACKUP
}
context.startService(stopIntent)
} catch (_: Exception) {}
}
}
},
enabled = !isRunning && selectedApps.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(12.dp)
onClick = { viewModel.executeBackup(context) },
enabled = !state.isRunning && state.selectedApps.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(12.dp),
) {
if (isRunning) {
if (state.isRunning) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
}
Text("开始备份 (${selectedApps.size})")
Text("开始备份 (${state.selectedApps.size})")
}
}
}
@@ -311,27 +124,27 @@ private fun AppListItem(
isSelected: Boolean,
isDataExcluded: Boolean,
onToggle: (Boolean) -> Unit,
onExcludeDataToggle: (Boolean) -> Unit
onExcludeDataToggle: (Boolean) -> Unit,
) {
Card(
onClick = { onToggle(!isSelected) },
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = isSelected, onCheckedChange = { onToggle(it) })
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = app.label.ifEmpty { app.packageName.value },
style = MaterialTheme.typography.bodyLarge
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = app.packageName.value,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (isSelected) {
@@ -339,8 +152,7 @@ private fun AppListItem(
Text(
"数据",
textDecoration = if (isDataExcluded) TextDecoration.LineThrough else TextDecoration.None,
color = if (isDataExcluded) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.primary
color = if (isDataExcluded) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
}
}

View File

@@ -0,0 +1,344 @@
package com.example.androidbackupgui.ui
import android.app.Application
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.androidbackupgui.backup.*
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_START_BACKUP
import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_STOP_BACKUP
import com.example.androidbackupgui.backup.BackupService.Companion.EXTRA_STATUS_TEXT
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Locale
enum class SortMode { NAME_ASC, SIZE_DESC }
/** Backup 界面的完整 UI 状态。 */
data class BackupUiState(
val config: BackupConfig = BackupConfig(),
val allApps: List<AppInfo> = emptyList(),
val sortedApps: List<AppInfo> = emptyList(),
val selectedApps: Set<String> = emptySet(),
val excludeDataFromBackup: Set<String> = emptySet(),
val sortMode: SortMode = SortMode.NAME_ASC,
val showSystemApps: Boolean = false,
val statusText: String = "请先扫描应用",
val isRunning: Boolean = false,
val isScanning: Boolean = false,
)
/** 备份操作的一次性事件。 */
sealed interface BackupEvent {
data class Error(
val message: String,
) : BackupEvent
data class BackupCompleted(
val result: BackupOperation.BackupResult,
) : BackupEvent
}
class BackupViewModel(
application: Application,
) : AndroidViewModel(application) {
companion object {
private const val TAG = "BackupViewModel"
}
private val _state = MutableStateFlow(BackupUiState())
val state: StateFlow<BackupUiState> = _state.asStateFlow()
private var currentJob: Job? = null
init {
// 加载配置文件
val cfg = BackupConfig.fromFile(File(application.filesDir, "backup_settings.conf"))
_state.update { it.copy(config = cfg) }
}
// ── 应用列表排序/过滤 ──────────────────────────────
fun applySortAndFilter() {
val s = _state.value
val filtered = if (s.showSystemApps) s.allApps else s.allApps.filter { !it.isSystem }
val sorted =
when (s.sortMode) {
SortMode.NAME_ASC -> filtered.sortedBy { it.label.lowercase(Locale.US) }
SortMode.SIZE_DESC -> filtered.sortedByDescending { it.backupSize }
}
_state.update { it.copy(sortedApps = sorted) }
}
fun setSortMode(mode: SortMode) {
_state.update { it.copy(sortMode = mode) }
applySortAndFilter()
}
fun toggleShowSystem() {
_state.update { it.copy(showSystemApps = !it.showSystemApps) }
applySortAndFilter()
}
fun selectAll() {
val pkgs =
_state.value.sortedApps
.map { it.packageName.value }
.toSet()
_state.update { it.copy(selectedApps = pkgs) }
}
fun clearSelection() {
_state.update { it.copy(selectedApps = emptySet()) }
}
fun toggleApp(
packageName: String,
checked: Boolean,
) {
_state.update { s ->
s.copy(selectedApps = if (checked) s.selectedApps + packageName else s.selectedApps - packageName)
}
}
fun toggleExcludeData(
packageName: String,
excluded: Boolean,
) {
_state.update { s ->
s.copy(excludeDataFromBackup = if (excluded) s.excludeDataFromBackup + packageName else s.excludeDataFromBackup - packageName)
}
}
// ── 扫描应用 ────────────────────────────────────────
fun scanApps(context: Context) {
if (_state.value.isScanning) return
_state.update { it.copy(isScanning = true, statusText = "正在扫描应用…") }
val config = _state.value.config
currentJob =
viewModelScope.launch {
try {
val userId = config.backupUserId
val thirdParty = withContext(Dispatchers.IO) { AppScanner.scanThirdParty(context, userId = userId) }
val system = withContext(Dispatchers.IO) { AppScanner.scanSystem(context, config, userId = userId) }
val apps = if (_state.value.showSystemApps) thirdParty + system else thirdParty
val allPkgNames = apps.map { it.packageName.value }.toSet()
var excludeSet = emptySet<String>()
val appListFile = File(context.filesDir, "appList.txt")
if (appListFile.exists()) {
val content = appListFile.readText()
val parsed = AppScanner.parseAppList(content)
val fromPrefix = parsed.filter { it.first in allPkgNames && !it.second }.map { it.first }.toSet()
if (fromPrefix.isNotEmpty()) excludeSet = fromPrefix
}
_state.update {
it.copy(
allApps = apps,
sortedApps = apps,
selectedApps = allPkgNames,
excludeDataFromBackup = excludeSet,
statusText =
if (excludeSet.isNotEmpty()) {
"共找到 ${apps.size} 个应用,${excludeSet.size} 个标记为仅APK"
} else {
"共找到 ${apps.size} 个应用,全部已选中"
},
isScanning = false,
)
}
} catch (e: Exception) {
_state.update { it.copy(statusText = "扫描应用失败: ${e.message}", isScanning = false) }
}
}
}
// ── 执行备份 ────────────────────────────────────────
fun executeBackup(context: Context) {
val s = _state.value
val toBackup = s.allApps.filter { it.packageName.value in s.selectedApps }
if (toBackup.isEmpty()) return
_state.update { it.copy(isRunning = true, statusText = "开始备份 ${toBackup.size} 个应用…") }
currentJob =
viewModelScope.launch {
try {
// 1. 启动前台服务
val serviceIntent =
Intent(context, BackupService::class.java).apply {
action = ACTION_START_BACKUP
putExtra(EXTRA_STATUS_TEXT, "正在备份 ${toBackup.size} 个应用…")
}
try {
ContextCompat.startForegroundService(context, serviceIntent)
} catch (_: Exception) {
}
// 2. 执行备份
val outputDir = File(s.config.outputPath.ifEmpty { context.filesDir.absolutePath })
val backupResult =
withContext(Dispatchers.IO) {
BackupOperation.backupApps(
context = context,
apps = toBackup,
config = s.config,
outputDir = outputDir,
userId = s.config.backupUserId.toString(),
noDataBackup = s.excludeDataFromBackup,
onProgress = { progress ->
_state.update {
it.copy(
statusText = "[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}",
)
}
},
)
}
_state.update {
it.copy(
statusText = "备份完成!成功: ${backupResult.successCount} 失败: ${backupResult.failCount} 耗时: ${backupResult.elapsedMs / 1000}s",
)
}
// 3. WiFi 备份
if (s.config.backupWifi == 1) {
WifiManager.backup(File(backupResult.outputDir))
}
// 4. Restic 上传
if (s.config.resticEnabled == 1 && s.config.resticRepo.isNotBlank()) {
executeResticBackup(context, toBackup, s, backupResult)
}
} catch (e: Exception) {
val hint =
when {
e.message?.contains("EPERM", ignoreCase = true) == true -> "写入备份目录被拒绝,请检查输出路径权限"
e.message?.contains("EACCES", ignoreCase = true) == true -> "权限不足,请检查存储权限"
else -> null
}
_state.update { it.copy(statusText = "备份异常: ${e.message}" + (hint?.let { " ($it)" } ?: "")) }
} finally {
_state.update { it.copy(isRunning = false) }
try {
context.startService(Intent(context, BackupService::class.java).apply { action = ACTION_STOP_BACKUP })
} catch (_: Exception) {
}
}
}
}
private suspend fun executeResticBackup(
context: Context,
toBackup: List<AppInfo>,
s: BackupUiState,
backupResult: BackupOperation.BackupResult,
) {
val binaryPath = ResticBinary.prepare(context) ?: return
defaultResticWrapper.binaryPath = binaryPath
defaultResticWrapper.cacheDir = context.cacheDir.absolutePath
defaultResticWrapper.backendDomain = s.config.resticBackendDomain
val password = PasswordManager.getResticPassword() ?: s.config.resticPassword.takeIf { it.isNotEmpty() } ?: ""
val backendPass = PasswordManager.getBackendPass() ?: s.config.resticBackendPass.takeIf { it.isNotEmpty() } ?: ""
if (s.config.useStreaming == 1) {
defaultResticWrapper
.backupStreaming(
apps = toBackup,
noDataBackup = s.excludeDataFromBackup,
legacyApps = null,
ownPackageName = context.packageName,
userId = s.config.backupUserId.toString(),
repoPath = s.config.resticRepo,
password = password,
tags = listOf("backup_${System.currentTimeMillis() / 1000}"),
hostname = "android-backup-gui",
backend = s.config.resticBackend,
backendUrl = s.config.resticBackendUrl,
backendUser = s.config.resticBackendUser,
backendPass = backendPass,
backendShare = s.config.resticBackendShare,
onProgress = { msg -> _state.update { it.copy(statusText = msg) } },
).let { result ->
when (result) {
is AppResult.Success -> {
val summary = result.getOrNull()
_state.update {
it.copy(
statusText = "流式备份完成ID: ${summary?.snapshotId?.take(
8,
)} 新增: ${(summary?.dataAdded ?: 0) / 1024 / 1024} MB",
)
}
}
is AppResult.Failure -> {
_state.update { it.copy(statusText = "流式备份失败: ${result.errorOrNull()?.message}") }
}
}
}
} else {
defaultResticWrapper
.backup(
repoPath = s.config.resticRepo,
password = password,
paths = listOf(backupResult.outputDir),
tags = listOf("backup_${System.currentTimeMillis() / 1000}"),
hostname = "android-backup-gui",
backend = s.config.resticBackend,
backendUrl = s.config.resticBackendUrl,
backendUser = s.config.resticBackendUser,
backendPass = backendPass,
backendShare = s.config.resticBackendShare,
onProgress = { progress ->
if (progress.messageType == "status") {
_state.update {
it.copy(
statusText =
"去重仓库: %.0f%% (%d/%d 个文件)".format(
progress.percentDone * 100,
progress.filesDone,
progress.totalFiles,
),
)
}
}
},
).let { result ->
when (result) {
is AppResult.Success -> {
val summary = result.getOrNull()
_state.update {
it.copy(
statusText = "备份完成Restic ID: ${summary?.snapshotId?.take(
8,
)} 新增: ${(summary?.dataAdded ?: 0) / 1024 / 1024} MB",
)
}
}
is AppResult.Failure -> {
_state.update { it.copy(statusText = "restic 快照失败: ${result.errorOrNull()?.message}") }
}
}
}
}
}
}

View File

@@ -27,7 +27,7 @@ import kotlinx.coroutines.withContext
@Composable
fun ConfigScreen(
viewModel: ConfigViewModel = viewModel(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -56,6 +56,7 @@ fun ConfigScreen(
var resticBackendPass by remember { mutableStateOf(config.resticBackendPass) }
var resticBackendShare by remember { mutableStateOf(config.resticBackendShare) }
var resticBackendDomain by remember { mutableStateOf(config.resticBackendDomain) }
var streamingEnabled by remember { mutableStateOf(config.useStreaming == 1) }
// Sync local state from ViewModel when config reloads
LaunchedEffect(config) {
@@ -76,30 +77,35 @@ fun ConfigScreen(
resticBackendPass = config.resticBackendPass
resticBackendShare = config.resticBackendShare
resticBackendDomain = config.resticBackendDomain
streamingEnabled = config.useStreaming == 1
}
// Load user list for backup user selector
LaunchedEffect(Unit) {
val users = withContext(Dispatchers.IO) {
AppScanner.enumerateUsers()
}
val users =
withContext(Dispatchers.IO) {
AppScanner.enumerateUsers()
}
userList = users
}
// Observe one-shot events → show Snackbar feedback
LaunchedEffect(snackbarHostState) {
viewModel.operationEvents.collect { event ->
val msg = when (event) {
is OperationEvent.InitCompleted -> "仓库初始化完成"
is OperationEvent.InitFailed -> "仓库初始化失败"
is OperationEvent.StatsCompleted -> "统计读取完成"
is OperationEvent.PruneStarted -> "正在清理快照…"
is OperationEvent.PruneCompleted -> "清理完成"
is OperationEvent.PruneFailed -> "清理失败"
is OperationEvent.ConfigExported -> "配置已导出"
is OperationEvent.ConfigExportFailed -> "配置导出失败"
else -> null
}
val msg =
when (event) {
is OperationEvent.InitCompleted -> "仓库初始化完成"
is OperationEvent.InitFailed -> "仓库初始化失败"
is OperationEvent.StatsCompleted -> "统计读取完成"
is OperationEvent.PruneStarted -> "正在清理快照…"
is OperationEvent.PruneCompleted -> "清理完成"
is OperationEvent.PruneFailed -> "清理失败"
is OperationEvent.ConfigExported -> "配置导出"
is OperationEvent.ConfigExportFailed -> "配置导出失败"
is OperationEvent.ConfigImported -> "配置已导入"
is OperationEvent.ConfigImportFailed -> "配置导入失败"
else -> null
}
if (msg != null) {
snackbarHostState.showSnackbar(msg)
}
@@ -109,18 +115,41 @@ fun ConfigScreen(
val scrollState = rememberScrollState()
// SAF launcher: create a .conf document at a user-chosen location, then export.
val exportLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/plain")
) { uri ->
if (uri != null) viewModel.exportConfig(uri)
}
val exportLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/plain"),
) { uri ->
if (uri != null) viewModel.exportConfig(uri)
}
// SAF launcher: pick a .conf file to import.
val importLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri ->
if (uri != null) viewModel.importConfig(uri)
}
// SAF directory picker for output path
val dirPickerLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { uri ->
if (uri != null) {
val resolvedPath = resolveSafTreeUri(uri)
if (resolvedPath != null) {
outputPath = resolvedPath
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
modifier =
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// ── Backup settings section ──
Text("备份设置", style = MaterialTheme.typography.titleMedium)
@@ -146,26 +175,35 @@ fun ConfigScreen(
Text("忽略运行中的应用", modifier = Modifier.weight(1f))
Switch(checked = ignoreRunning, onCheckedChange = { ignoreRunning = it })
}
OutlinedTextField(
value = outputPath,
onValueChange = { outputPath = it },
label = { Text("输出目录") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = outputPath,
onValueChange = { outputPath = it },
label = { Text("输出目录") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Spacer(Modifier.width(8.dp))
Button(
onClick = { dirPickerLauncher.launch(null) },
modifier = Modifier.height(56.dp),
) {
Text("选择")
}
}
OutlinedTextField(
value = compressionMethod,
onValueChange = { compressionMethod = it },
label = { Text("压缩方式 (tar / zstd)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
singleLine = true,
)
// Backup user selector
UserSelector(
userList = userList,
selectedUserId = backupUserId,
onUserSelected = { backupUserId = it }
onUserSelected = { backupUserId = it },
)
}
}
@@ -183,10 +221,13 @@ fun ConfigScreen(
if (resticEnabled) {
OutlinedTextField(
value = resticRepo,
onValueChange = { resticRepo = it; viewModel.onFormChanged(resticBackend, it, resticBackendUrl) },
onValueChange = {
resticRepo = it
viewModel.onFormChanged(resticBackend, it, resticBackendUrl)
},
label = { Text("仓库路径") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
singleLine = true,
)
OutlinedTextField(
value = resticPassword,
@@ -194,7 +235,9 @@ fun ConfigScreen(
label = { Text("仓库密码") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation()
visualTransformation =
androidx.compose.ui.text.input
.PasswordVisualTransformation(),
)
// Backend selection radio group
@@ -203,22 +246,22 @@ fun ConfigScreen(
Column(modifier = Modifier.selectableGroup()) {
backends.forEach { (value, label) ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = resticBackend == value,
onClick = {
resticBackend = value
viewModel.onFormChanged(value, resticRepo, resticBackendUrl)
},
role = Role.RadioButton
)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
modifier =
Modifier
.fillMaxWidth()
.selectable(
selected = resticBackend == value,
onClick = {
resticBackend = value
viewModel.onFormChanged(value, resticRepo, resticBackendUrl)
},
role = Role.RadioButton,
).padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = resticBackend == value,
onClick = null
onClick = null,
)
Spacer(Modifier.width(8.dp))
Text(label)
@@ -231,67 +274,89 @@ fun ConfigScreen(
Text(
text = "实际仓库: ${backendDisplay.computedUrl}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Remote-specific fields
if (resticBackend != "local") {
OutlinedTextField(
value = resticBackendUrl,
onValueChange = { resticBackendUrl = it; viewModel.onFormChanged(resticBackend, resticRepo, it) },
label = { Text(backendDisplay.urlHint.ifEmpty { "后端地址" }) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
}
if (resticBackend == "webdav" || resticBackend == "smb") {
OutlinedTextField(
value = resticBackendUser,
onValueChange = { resticBackendUser = it },
label = { Text("用户名") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
OutlinedTextField(
value = resticBackendPass,
onValueChange = { resticBackendPass = it },
label = { Text("密码") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation()
)
}
if (resticBackend == "smb") {
OutlinedTextField(
value = resticBackendShare,
onValueChange = { resticBackendShare = it },
label = { Text("SMB 共享名称") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
OutlinedTextField(
value = resticBackendDomain,
onValueChange = { resticBackendDomain = it },
label = { Text("SMB 域 (可选)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
}
OutlinedTextField(
value = resticBackendUrl,
onValueChange = {
resticBackendUrl = it
viewModel.onFormChanged(resticBackend, resticRepo, it)
},
label = { Text(backendDisplay.urlHint.ifEmpty { "后端地址" }) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
if (resticBackend == "webdav" || resticBackend == "smb") {
OutlinedTextField(
value = resticBackendUser,
onValueChange = { resticBackendUser = it },
label = { Text("用户名") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = resticBackendPass,
onValueChange = { resticBackendPass = it },
label = { Text("密码") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation =
androidx.compose.ui.text.input
.PasswordVisualTransformation(),
)
}
if (resticBackend == "smb") {
OutlinedTextField(
value = resticBackendShare,
onValueChange = { resticBackendShare = it },
label = { Text("SMB 共享名称") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = resticBackendDomain,
onValueChange = { resticBackendDomain = it },
label = { Text("SMB 域 (可选)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
// ── Streaming backup toggle ──
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"流式备份 (FIFO管道 → restic --stdin)",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
)
Switch(
checked = streamingEnabled,
onCheckedChange = { streamingEnabled = it },
)
}
Spacer(Modifier.height(8.dp))
// Status & action buttons
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier.fillMaxWidth()
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = status.message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
@@ -300,11 +365,20 @@ fun ConfigScreen(
Button(
onClick = {
viewModel.initResticRepo(
buildResticForm(resticRepo, resticPassword, resticBackend, resticBackendUrl, resticBackendUser, resticBackendPass, resticBackendShare, resticBackendDomain)
buildResticForm(
resticRepo,
resticPassword,
resticBackend,
resticBackendUrl,
resticBackendUser,
resticBackendPass,
resticBackendShare,
resticBackendDomain,
),
)
},
enabled = status.initButtonEnabled,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
) {
Text("初始化仓库")
}
@@ -314,11 +388,20 @@ fun ConfigScreen(
Button(
onClick = {
viewModel.showResticStats(
buildResticForm(resticRepo, resticPassword, resticBackend, resticBackendUrl, resticBackendUser, resticBackendPass, resticBackendShare, resticBackendDomain)
buildResticForm(
resticRepo,
resticPassword,
resticBackend,
resticBackendUrl,
resticBackendUser,
resticBackendPass,
resticBackendShare,
resticBackendDomain,
),
)
},
enabled = status.statsButtonEnabled,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
) {
Text("仓库统计")
}
@@ -328,11 +411,20 @@ fun ConfigScreen(
OutlinedButton(
onClick = {
viewModel.pruneResticSnapshots(
buildResticForm(resticRepo, resticPassword, resticBackend, resticBackendUrl, resticBackendUser, resticBackendPass, resticBackendShare, resticBackendDomain)
buildResticForm(
resticRepo,
resticPassword,
resticBackend,
resticBackendUrl,
resticBackendUser,
resticBackendPass,
resticBackendShare,
resticBackendDomain,
),
)
},
enabled = status.pruneButtonEnabled,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
) {
Text("清理旧快照")
}
@@ -342,14 +434,24 @@ fun ConfigScreen(
Button(
onClick = {
viewModel.unlockResticRepo(
buildResticForm(resticRepo, resticPassword, resticBackend, resticBackendUrl, resticBackendUser, resticBackendPass, resticBackendShare, resticBackendDomain)
buildResticForm(
resticRepo,
resticPassword,
resticBackend,
resticBackendUrl,
resticBackendUser,
resticBackendPass,
resticBackendShare,
resticBackendDomain,
),
)
},
enabled = status.unlockButtonEnabled,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.tertiary
)
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.tertiary,
),
) {
Text("解锁仓库")
}
@@ -384,30 +486,42 @@ fun ConfigScreen(
resticBackendPass = resticBackendPass,
resticBackendShare = resticBackendShare,
resticBackendDomain = resticBackendDomain,
)
useStreaming = if (streamingEnabled) 1 else 0,
),
)
},
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Save, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("保存配置")
}
// ── Export config button ──
OutlinedButton(
onClick = { exportLauncher.launch("backup_settings.conf") },
modifier = Modifier.fillMaxWidth()
// ── Import / Export config buttons ──
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Filled.FileUpload, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("导出配置")
OutlinedButton(
onClick = { importLauncher.launch(arrayOf("text/plain", "*/*")) },
modifier = Modifier.weight(1f),
) {
Text("导入配置")
}
OutlinedButton(
onClick = { exportLauncher.launch("backup_settings.conf") },
modifier = Modifier.weight(1f),
) {
Icon(Icons.Filled.FileUpload, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("导出配置")
}
}
if (resticEnabled && resticPassword.isNotEmpty()) {
Text(
text = "注意:导出的配置包含明文 Restic 密码,请妥善保管导出的文件。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
color = MaterialTheme.colorScheme.error,
)
}
@@ -422,12 +536,13 @@ fun ConfigScreen(
private fun UserSelector(
userList: List<Pair<Int, String>>,
selectedUserId: Int,
onUserSelected: (Int) -> Unit
onUserSelected: (Int) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val selectedName = userList.find { it.first == selectedUserId }?.let {
"${it.second} (ID: ${it.first})"
} ?: "Owner (ID: 0)"
val selectedName =
userList.find { it.first == selectedUserId }?.let {
"${it.second} (ID: ${it.first})"
} ?: "Owner (ID: 0)"
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
@@ -437,13 +552,16 @@ private fun UserSelector(
label = { Text("备份用户") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
singleLine = true
singleLine = true,
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
userList.forEach { (id, name) ->
DropdownMenuItem(
text = { Text("$name (ID: $id)") },
onClick = { onUserSelected(id); expanded = false }
onClick = {
onUserSelected(id)
expanded = false
},
)
}
}
@@ -452,13 +570,46 @@ private fun UserSelector(
/** Build a [ResticForm] from current input values (matches ConfigFragment's readResticForm). */
private fun buildResticForm(
repo: String, password: String,
backend: String, backendUrl: String,
backendUser: String, backendPass: String,
backendShare: String, backendDomain: String
repo: String,
password: String,
backend: String,
backendUrl: String,
backendUser: String,
backendPass: String,
backendShare: String,
backendDomain: String,
) = ResticForm(
repo = repo, password = password,
backend = backend, backendUrl = backendUrl,
backendUser = backendUser, backendPass = backendPass,
backendShare = backendShare, backendDomain = backendDomain
repo = repo,
password = password,
backend = backend,
backendUrl = backendUrl,
backendUser = backendUser,
backendPass = backendPass,
backendShare = backendShare,
backendDomain = backendDomain,
)
/**
* 将 SAF OpenDocumentTree 的 content:// URI 转换为可用的文件系统路径。
* SAF URI 示例: content://com.android.externalstorage.documents/tree/primary%3ADownload%2FBackup
* 返回: /storage/emulated/0/Download/Backup
*/
private fun resolveSafTreeUri(uri: android.net.Uri): String? {
// SAF tree URI 格式:
// content://com.android.externalstorage.documents/tree/primary%3ADownload%2FBackup
// lastPathSegment = primary%3ADownload%2FBackup 或 XXXX-XXXX%3Apath
val docId = uri.lastPathSegment?.let { java.net.URLDecoder.decode(it, "UTF-8") } ?: return null
// docId 格式: primary:path/to/dir 或 XXXX-XXXX:path/to/dir
val colonIdx = docId.indexOf(':')
if (colonIdx < 0) return null
val storageId = docId.substring(0, colonIdx)
val relPath = docId.substring(colonIdx + 1).trim('/')
return if (storageId.equals("primary", ignoreCase = true)) {
"/storage/emulated/0/$relPath"
} else {
"/storage/$storageId/$relPath"
}
}

View File

@@ -1,24 +1,25 @@
package com.example.androidbackupgui.ui
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.androidbackupgui.backup.BackupConfig
import com.example.androidbackupgui.backup.formatSize
import com.example.androidbackupgui.backup.PasswordManager
import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.ResticWrapper
import com.example.androidbackupgui.backup.defaultResticWrapper
import com.example.androidbackupgui.backup.formatSize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
@@ -27,7 +28,7 @@ import java.util.concurrent.atomic.AtomicBoolean
data class ConfigUiState(
val config: BackupConfig = BackupConfig(),
val backendDisplay: BackendDisplay = BackendDisplay(),
val resticStatus: ResticStatus = ResticStatus()
val resticStatus: ResticStatus = ResticStatus(),
)
data class BackendDisplay(
@@ -35,7 +36,7 @@ data class BackendDisplay(
val needsAuth: Boolean = false,
val isSmb: Boolean = false,
val computedUrl: String = "",
val urlHint: String = ""
val urlHint: String = "",
)
data class ResticStatus(
@@ -48,15 +49,19 @@ data class ResticStatus(
val pruneButtonVisible: Boolean = false,
val pruneButtonEnabled: Boolean = true,
val unlockButtonVisible: Boolean = false,
val unlockButtonEnabled: Boolean = true
val unlockButtonEnabled: Boolean = true,
)
/** Restic credential/form snapshot passed from Fragment on every user interaction. */
data class ResticForm(
val repo: String, val password: String,
val backend: String, val backendUrl: String,
val backendUser: String, val backendPass: String,
val backendShare: String, val backendDomain: String
val repo: String,
val password: String,
val backend: String,
val backendUrl: String,
val backendUser: String,
val backendPass: String,
val backendShare: String,
val backendDomain: String,
)
/**
@@ -65,40 +70,61 @@ data class ResticForm(
*/
sealed interface OperationEvent {
data object InitStarted : OperationEvent
data object InitCompleted : OperationEvent
data object InitFailed : OperationEvent
data object StatsStarted : OperationEvent
data object StatsCompleted : OperationEvent
data object PruneStarted : OperationEvent
data object PruneFailed : OperationEvent
data object PruneCompleted : OperationEvent
data object ConfigExported : OperationEvent
data object ConfigExportFailed : OperationEvent
data object ConfigImported : OperationEvent
data object ConfigImportFailed : OperationEvent
}
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
class ConfigViewModel(
application: Application,
) : AndroidViewModel(application) {
companion object {
private const val TAG = "ConfigViewModel"
private const val CONFIG_FILE_NAME = "backup_settings.conf"
fun deriveBackendDisplay(backend: String, repo: String, backendUrl: String): BackendDisplay {
fun deriveBackendDisplay(
backend: String,
repo: String,
backendUrl: String,
): BackendDisplay {
val isRemote = backend != "local"
val needsAuth = backend == "webdav" || backend == "smb"
val isSmb = backend == "smb"
val urlHint = when (backend) {
"webdav" -> "WebDAV 地址 (https://host:port/path)"
"smb" -> "SMB 主机地址 (host 或 host:port)"
"rest-server" -> "rest-server 地址 (http://host:port)"
else -> ""
}
val computedUrl = ResticWrapper.buildRepoUrl(backend, repo, backendUrl)
val urlHint =
when (backend) {
"webdav" -> "WebDAV 地址 (https://host:port/path)"
"smb" -> "SMB 主机地址 (host 或 host:port)"
"rest-server" -> "rest-server 地址 (http://host:port)"
else -> ""
}
val computedUrl = defaultResticWrapper.buildRepoUrl(backend, repo, backendUrl)
return BackendDisplay(
isRemote = isRemote, needsAuth = needsAuth, isSmb = isSmb,
computedUrl = computedUrl, urlHint = urlHint
isRemote = isRemote,
needsAuth = needsAuth,
isSmb = isSmb,
computedUrl = computedUrl,
urlHint = urlHint,
)
}
}
private val configFile: File by lazy {
@@ -132,18 +158,40 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
refreshResticStatus(readResticForm())
}
/** Build a [ResticForm] snapshot from the current state's config values. */
private fun readResticForm() = _uiState.value.config.let { c ->
ResticForm(
repo = c.resticRepo, password = c.resticPassword,
backend = c.resticBackend, backendUrl = c.resticBackendUrl,
backendUser = c.resticBackendUser, backendPass = c.resticBackendPass,
backendShare = c.resticBackendShare, backendDomain = c.resticBackendDomain
)
}
/**
* Build a [ResticForm] snapshot from the current state's config values.
* 密码从 PasswordManager加密存储获取不从配置文件读取。
*/
private fun readResticForm() =
_uiState.value.config.let { c ->
// 从加密存储获取密码,如尚未设置则尝试从旧配置迁移
val password = PasswordManager.getResticPassword() ?: c.resticPassword.takeIf { it.isNotEmpty() }
val backendPass = PasswordManager.getBackendPass() ?: c.resticBackendPass.takeIf { it.isNotEmpty() }
// 如果发现旧配置中有密码但 PasswordManager 还没有,迁移过去
if (password != null && !PasswordManager.hasResticPassword() && password != "stored-in-keystore") {
PasswordManager.setResticPassword(password)
}
if (backendPass != null && backendPass != "stored-in-keystore" && PasswordManager.getBackendPass() == null) {
PasswordManager.setBackendPass(backendPass)
}
ResticForm(
repo = c.resticRepo,
password = password ?: "",
backend = c.resticBackend,
backendUrl = c.resticBackendUrl,
backendUser = c.resticBackendUser,
backendPass = backendPass ?: "",
backendShare = c.resticBackendShare,
backendDomain = c.resticBackendDomain,
)
}
/** Update derived display state when backend/repo/url form fields change. */
fun onFormChanged(backend: String, repo: String, backendUrl: String) {
fun onFormChanged(
backend: String,
repo: String,
backendUrl: String,
) {
val bd = deriveBackendDisplay(backend, repo, backendUrl)
_uiState.update { it.copy(backendDisplay = bd) }
}
@@ -151,21 +199,34 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
/**
* Save config to file on IO and update status message.
* The caller passes the current form values as a [BackupConfig] copy.
* 密码单独通过 [PasswordManager] 安全存储,不入配置文件。
*/
fun save(formConfig: BackupConfig) {
fun save(
formConfig: BackupConfig,
resticPassword: String? = null,
backendPass: String? = null,
) {
viewModelScope.launch {
// 保存密码到加密存储
if (resticPassword != null && resticPassword.isNotEmpty()) {
PasswordManager.setResticPassword(resticPassword)
}
if (backendPass != null && backendPass.isNotEmpty()) {
PasswordManager.setBackendPass(backendPass)
}
withContext(Dispatchers.IO) {
BackupConfig.toFile(formConfig, configFile)
}
_uiState.update {
it.copy(
config = formConfig,
backendDisplay = deriveBackendDisplay(
formConfig.resticBackend,
formConfig.resticRepo,
formConfig.resticBackendUrl
),
resticStatus = it.resticStatus.copy(message = "配置已保存到 $configFile")
backendDisplay =
deriveBackendDisplay(
formConfig.resticBackend,
formConfig.resticRepo,
formConfig.resticBackendUrl,
),
resticStatus = it.resticStatus.copy(message = "配置已保存到 $configFile"),
)
}
refreshResticStatus(readResticForm())
@@ -179,31 +240,42 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
*/
fun exportConfig(uri: android.net.Uri) {
viewModelScope.launch {
val ok = withContext(Dispatchers.IO) {
try {
// Ensure the latest saved config exists; serialize current UI config
// if the file isn't there yet.
val content = if (configFile.exists()) {
configFile.readText()
} else {
val tmp = File.createTempFile("cfg", ".conf", getApplication<Application>().cacheDir)
BackupConfig.toFile(_uiState.value.config, tmp)
tmp.readText().also { tmp.delete() }
val ok =
withContext(Dispatchers.IO) {
try {
// Ensure the latest saved config exists; serialize current UI config
// if the file isn't there yet.
val content =
if (configFile.exists()) {
configFile.readText()
} else {
val tmp = File.createTempFile("cfg", ".conf", getApplication<Application>().cacheDir)
BackupConfig.toFile(_uiState.value.config, tmp)
tmp.readText().also { tmp.delete() }
}
getApplication<Application>()
.contentResolver
.openOutputStream(uri)
?.use { out ->
out.write(content.toByteArray())
out.flush()
} ?: return@withContext false
true
} catch (e: Exception) {
Log.e(TAG, "exportConfig failed", e)
false
}
getApplication<Application>().contentResolver
.openOutputStream(uri)?.use { out ->
out.write(content.toByteArray())
out.flush()
} ?: return@withContext false
true
} catch (e: Exception) {
Log.e(TAG, "exportConfig failed", e)
false
}
}
if (ok) {
_operationEvents.emit(OperationEvent.ConfigExported)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "配置已导出")) }
_uiState.update {
it.copy(
resticStatus =
it.resticStatus.copy(
message = "配置已导出(密码未包含,需在目标设备上通过应用重新输入)",
),
)
}
} else {
_operationEvents.emit(OperationEvent.ConfigExportFailed)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "配置导出失败")) }
@@ -211,13 +283,78 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
}
}
/**
* Import config from a user-selected [Uri] (SAF).
* Reads the content, writes to configFile, and reloads UI state.
*/
fun importConfig(uri: android.net.Uri) {
viewModelScope.launch {
val ok =
withContext(Dispatchers.IO) {
try {
val content =
getApplication<Application>()
.contentResolver
.openInputStream(uri)
?.use { input -> input.reader().readText() }
?: return@withContext false
configFile.writeText(content)
val parsed = BackupConfig.fromFile(configFile)
// 导入的配置中密码是 "stored-in-keystore" 占位符,
// 需要从 PasswordManager 恢复真实密码,避免被覆盖
val realResticPw = PasswordManager.getResticPassword()
val realBackendPw = PasswordManager.getBackendPass()
val restoredConfig =
parsed.copy(
resticPassword = realResticPw ?: parsed.resticPassword,
resticBackendPass = realBackendPw ?: parsed.resticBackendPass,
)
_uiState.update { it.copy(config = restoredConfig) }
Log.i(TAG, "importConfig: loaded config from SAF")
true
} catch (e: Exception) {
Log.e(TAG, "importConfig failed", e)
false
}
}
if (ok) {
_operationEvents.emit(OperationEvent.ConfigImported)
_uiState.update {
it.copy(
resticStatus =
it.resticStatus.copy(
message = "配置已导入,请检查各项设置并保存",
),
)
}
// Reload UI state from imported config保留已有的密码
val s = _uiState.value
refreshResticStatus(
ResticForm(
repo = s.config.resticRepo,
password = PasswordManager.getResticPassword() ?: "",
backend = s.config.resticBackend,
backendUrl = s.config.resticBackendUrl,
backendUser = s.config.resticBackendUser,
backendPass = PasswordManager.getBackendPass() ?: "",
backendShare = s.config.resticBackendShare,
backendDomain = s.config.resticBackendDomain,
),
)
} else {
_operationEvents.emit(OperationEvent.ConfigImportFailed)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "配置导入失败")) }
}
}
}
/** Prepare ResticWrapper (binary, temp dir, domain) from application context. */
private fun prepareRestic(): Boolean {
val ctx = getApplication<Application>()
val binaryPath = ResticBinary.prepare(ctx)
if (binaryPath == null) return false
ResticWrapper.binaryPath = binaryPath
ResticWrapper.cacheDir = ctx.cacheDir.absolutePath
defaultResticWrapper.binaryPath = binaryPath
defaultResticWrapper.cacheDir = ctx.cacheDir.absolutePath
return true
}
@@ -231,12 +368,17 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
Log.i(TAG, "initResticRepo called: repo=${form.repo} backend=${form.backend}")
if (!prepareRestic()) {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "restic 二进制未就绪,请确保已安装 restic 于 Termux 或 APK 内置版本可用"
))}
_uiState.update {
it.copy(
resticStatus =
it.resticStatus.copy(
message = "restic 二进制未就绪,请确保已安装 restic 于 Termux 或 APK 内置版本可用",
),
)
}
return
}
ResticWrapper.backendDomain = form.backendDomain
defaultResticWrapper.backendDomain = form.backendDomain
Log.i(TAG, "initResticRepo: repo=${form.repo} backend=${form.backend} url=${form.backendUrl}")
if (form.repo.isEmpty() || form.password.isEmpty()) {
@@ -244,30 +386,51 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
return
}
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "正在初始化 restic 仓库…", initButtonEnabled = false
))}
_uiState.update {
it.copy(
resticStatus =
it.resticStatus.copy(
message = "正在初始化 restic 仓库…",
initButtonEnabled = false,
),
)
}
viewModelScope.launch {
try {
_operationEvents.emit(OperationEvent.InitStarted)
val result = ResticWrapper.init(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
)
val result =
defaultResticWrapper.init(
form.repo,
form.password,
backend = form.backend,
backendUrl = form.backendUrl,
backendUser = form.backendUser,
backendPass = form.backendPass,
backendShare = form.backendShare,
)
if (result.isSuccess) {
_operationEvents.emit(OperationEvent.InitCompleted)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "仓库初始化成功: ${form.repo}"
))}
_uiState.update {
it.copy(
resticStatus =
it.resticStatus.copy(
message = "仓库初始化成功: ${form.repo}",
),
)
}
refreshResticStatus(form)
} else {
_operationEvents.emit(OperationEvent.InitFailed)
Log.e(TAG, "initResticRepo failed: ${result.exceptionOrNull()?.message}")
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "初始化失败: ${result.exceptionOrNull()?.message}"
))}
_uiState.update {
it.copy(
resticStatus =
it.resticStatus.copy(
message = "初始化失败: ${result.exceptionOrNull()?.message}",
),
)
}
refreshResticStatus(form)
}
} finally {
@@ -278,131 +441,229 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
fun refreshResticStatus(form: ResticForm) {
if (form.repo.isBlank()) {
_uiState.update { it.copy(resticStatus = ResticStatus(
message = "请填写仓库路径和密码后初始化",
initButtonVisible = true, statsButtonVisible = false, pruneButtonVisible = false
))}
_uiState.update {
it.copy(
resticStatus =
ResticStatus(
message = "请填写仓库路径和密码后初始化",
initButtonVisible = true,
statsButtonVisible = false,
pruneButtonVisible = false,
),
)
}
return
}
if (!prepareRestic()) {
_uiState.update { it.copy(resticStatus = ResticStatus(
message = "restic 二进制未就绪",
initButtonVisible = true, statsButtonVisible = false, pruneButtonVisible = false
))}
_uiState.update {
it.copy(
resticStatus =
ResticStatus(
message = "restic 二进制未就绪",
initButtonVisible = true,
statsButtonVisible = false,
pruneButtonVisible = false,
),
)
}
return
}
ResticWrapper.backendDomain = form.backendDomain
defaultResticWrapper.backendDomain = form.backendDomain
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "正在检测仓库状态…")) }
// Cancel any stale status check so a slow old coroutine doesn't overwrite new results
refreshJob?.cancel()
refreshJob = viewModelScope.launch {
val snapshotsResult = ResticWrapper.listSnapshots(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
)
if (snapshotsResult.isSuccess) {
val snapshots = snapshotsResult.getOrDefault(emptyList())
_uiState.update { it.copy(resticStatus = ResticStatus(
message = "仓库就绪,${snapshots.size} 个快照",
snapshotCount = snapshots.size,
initButtonVisible = false, statsButtonVisible = true, pruneButtonVisible = true,
unlockButtonVisible = true
))}
} else {
val errMsg = snapshotsResult.errorOrNull()?.message ?: ""
val hasLock = errMsg.contains("lock", ignoreCase = true) || errMsg.contains("already locked", ignoreCase = true)
if (hasLock) {
_uiState.update { it.copy(resticStatus = ResticStatus(
message = "仓库被锁定,请先解锁",
initButtonVisible = false, statsButtonVisible = false, pruneButtonVisible = false,
unlockButtonVisible = true
))}
} else {
// snapshots 失败时自动尝试 init处理已初始化的旧仓库
val initResult = ResticWrapper.init(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
refreshJob =
viewModelScope.launch {
val snapshotsResult =
defaultResticWrapper.listSnapshots(
form.repo,
form.password,
backend = form.backend,
backendUrl = form.backendUrl,
backendUser = form.backendUser,
backendPass = form.backendPass,
backendShare = form.backendShare,
)
if (initResult.isSuccess) {
val snaps = ResticWrapper.listSnapshots(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
).getOrDefault(emptyList())
_uiState.update { it.copy(resticStatus = ResticStatus(
message = "仓库就绪,${snaps.size} 个快照",
snapshotCount = snaps.size,
initButtonVisible = false, statsButtonVisible = true, pruneButtonVisible = true,
unlockButtonVisible = true
))}
if (snapshotsResult.isSuccess) {
val snapshots = snapshotsResult.getOrDefault(emptyList())
_uiState.update {
it.copy(
resticStatus =
ResticStatus(
message = "仓库就绪,${snapshots.size} 个快照",
snapshotCount = snapshots.size,
initButtonVisible = false,
statsButtonVisible = true,
pruneButtonVisible = true,
unlockButtonVisible = true,
),
)
}
} else {
val errMsg = snapshotsResult.errorOrNull()?.message ?: ""
val hasLock = errMsg.contains("lock", ignoreCase = true) || errMsg.contains("already locked", ignoreCase = true)
if (hasLock) {
_uiState.update {
it.copy(
resticStatus =
ResticStatus(
message = "仓库被锁定,请先解锁",
initButtonVisible = false,
statsButtonVisible = false,
pruneButtonVisible = false,
unlockButtonVisible = true,
),
)
}
} else {
_uiState.update { it.copy(resticStatus = ResticStatus(
message = "仓库未初始化或认证失败",
initButtonVisible = true, statsButtonVisible = false, pruneButtonVisible = false,
unlockButtonVisible = false
))}
// snapshots 失败时自动尝试 init处理已初始化的旧仓库
val initResult =
defaultResticWrapper.init(
form.repo,
form.password,
backend = form.backend,
backendUrl = form.backendUrl,
backendUser = form.backendUser,
backendPass = form.backendPass,
backendShare = form.backendShare,
)
if (initResult.isSuccess) {
val snaps =
defaultResticWrapper
.listSnapshots(
form.repo,
form.password,
backend = form.backend,
backendUrl = form.backendUrl,
backendUser = form.backendUser,
backendPass = form.backendPass,
backendShare = form.backendShare,
).getOrDefault(emptyList())
_uiState.update {
it.copy(
resticStatus =
ResticStatus(
message = "仓库就绪,${snaps.size} 个快照",
snapshotCount = snaps.size,
initButtonVisible = false,
statsButtonVisible = true,
pruneButtonVisible = true,
unlockButtonVisible = true,
),
)
}
} else {
_uiState.update {
it.copy(
resticStatus =
ResticStatus(
message = "仓库未初始化或认证失败",
initButtonVisible = true,
statsButtonVisible = false,
pruneButtonVisible = false,
unlockButtonVisible = false,
),
)
}
}
}
}
}
}
}
fun unlockResticRepo(form: ResticForm) {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "正在解锁仓库…", unlockButtonEnabled = false
))}
viewModelScope.launch {
ResticWrapper.backendDomain = form.backendDomain
val result = ResticWrapper.unlock(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
_uiState.update {
it.copy(
resticStatus =
it.resticStatus.copy(
message = "正在解锁仓库…",
unlockButtonEnabled = false,
),
)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = if (result.isSuccess) "解锁完成" else "解锁失败: ${result.errorOrNull()?.message}",
unlockButtonEnabled = true
))}
}
viewModelScope.launch {
defaultResticWrapper.backendDomain = form.backendDomain
val result =
defaultResticWrapper.unlock(
form.repo,
form.password,
backend = form.backend,
backendUrl = form.backendUrl,
backendUser = form.backendUser,
backendPass = form.backendPass,
backendShare = form.backendShare,
)
_uiState.update {
it.copy(
resticStatus =
it.resticStatus.copy(
message = if (result.isSuccess) "解锁完成" else "解锁失败: ${result.errorOrNull()?.message}",
unlockButtonEnabled = true,
),
)
}
refreshResticStatus(form)
}
}
fun showResticStats(form: ResticForm) {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "正在读取统计…", statsButtonEnabled = false
))}
_uiState.update {
it.copy(
resticStatus =
it.resticStatus.copy(
message = "正在读取统计…",
statsButtonEnabled = false,
),
)
}
viewModelScope.launch {
try {
_operationEvents.emit(OperationEvent.StatsStarted)
val statsResult = ResticWrapper.stats(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
)
val snapshotsResult = ResticWrapper.listSnapshots(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
)
val statsResult =
defaultResticWrapper.stats(
form.repo,
form.password,
backend = form.backend,
backendUrl = form.backendUrl,
backendUser = form.backendUser,
backendPass = form.backendPass,
backendShare = form.backendShare,
)
val snapshotsResult =
defaultResticWrapper.listSnapshots(
form.repo,
form.password,
backend = form.backend,
backendUrl = form.backendUrl,
backendUser = form.backendUser,
backendPass = form.backendPass,
backendShare = form.backendShare,
)
val snapshotCount = snapshotsResult.getOrDefault(emptyList()).size
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = buildString {
appendLine("快照数: $snapshotCount")
if (statsResult.isSuccess) {
appendLine(statsResult.getOrDefault(""))
} else {
appendLine("统计读取失败: ${statsResult.errorOrNull()?.message}")
}
},
snapshotCount = snapshotCount,
statsButtonEnabled = true
))}
_uiState.update {
it.copy(
resticStatus =
it.resticStatus.copy(
message =
buildString {
appendLine("快照数: $snapshotCount")
if (statsResult.isSuccess) {
appendLine(statsResult.getOrDefault(""))
} else {
appendLine("统计读取失败: ${statsResult.errorOrNull()?.message}")
}
},
snapshotCount = snapshotCount,
statsButtonEnabled = true,
),
)
}
_operationEvents.emit(OperationEvent.StatsCompleted)
} finally {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(statsButtonEnabled = true)) }
@@ -411,52 +672,85 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
}
fun pruneResticSnapshots(form: ResticForm) {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "正在清理旧快照 (保留 7 天 / 4 周 / 3 月)…",
pruneButtonEnabled = false
))}
_uiState.update {
it.copy(
resticStatus =
it.resticStatus.copy(
message = "正在清理旧快照 (保留 7 天 / 4 周 / 3 月)…",
pruneButtonEnabled = false,
),
)
}
viewModelScope.launch {
try {
_operationEvents.emit(OperationEvent.PruneStarted)
// Remove stale locks before forget/prune
ResticWrapper.backendDomain = form.backendDomain
ResticWrapper.unlock(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
defaultResticWrapper.backendDomain = form.backendDomain
defaultResticWrapper.unlock(
form.repo,
form.password,
backend = form.backend,
backendUrl = form.backendUrl,
backendUser = form.backendUser,
backendPass = form.backendPass,
backendShare = form.backendShare,
)
val forgetResult = ResticWrapper.forget(form.repo, form.password,
keepDaily = 7, keepWeekly = 4, keepMonthly = 3,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
)
val forgetResult =
defaultResticWrapper.forget(
form.repo,
form.password,
keepDaily = 7,
keepWeekly = 4,
keepMonthly = 3,
backend = form.backend,
backendUrl = form.backendUrl,
backendUser = form.backendUser,
backendPass = form.backendPass,
backendShare = form.backendShare,
)
if (forgetResult.isFailure) {
_operationEvents.emit(OperationEvent.PruneFailed)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "forget 失败: ${forgetResult.exceptionOrNull()?.message}",
pruneButtonEnabled = true
))}
_uiState.update {
it.copy(
resticStatus =
it.resticStatus.copy(
message = "forget 失败: ${forgetResult.exceptionOrNull()?.message}",
pruneButtonEnabled = true,
),
)
}
return@launch
}
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "正在回收空间…")) }
val pruneResult = ResticWrapper.prune(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = if (pruneResult.isSuccess)
"清理完成!\n${pruneResult.getOrDefault("")}"
else
"prune 失败: ${pruneResult.exceptionOrNull()?.message}",
pruneButtonEnabled = true
))}
val pruneResult =
defaultResticWrapper.prune(
form.repo,
form.password,
backend = form.backend,
backendUrl = form.backendUrl,
backendUser = form.backendUser,
backendPass = form.backendPass,
backendShare = form.backendShare,
)
_uiState.update {
it.copy(
resticStatus =
it.resticStatus.copy(
message =
if (pruneResult.isSuccess) {
"清理完成!建议执行完整性检查 (check --read-data-subset=5%)"
} else {
"prune 失败: ${pruneResult.exceptionOrNull()?.message}"
},
pruneButtonEnabled = true,
),
)
}
if (pruneResult.isSuccess) {
_operationEvents.emit(OperationEvent.PruneCompleted)
} else {
@@ -467,6 +761,4 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
}
}
}
}

View File

@@ -0,0 +1,202 @@
package com.example.androidbackupgui.ui
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.example.androidbackupgui.backup.LogUtil
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LogScreen() {
val context = LocalContext.current
var logFiles by remember { mutableStateOf(listOf<File>()) }
var selectedFile by remember { mutableStateOf<File?>(null) }
var logContent by remember { mutableStateOf<List<String>>(emptyList()) }
val scope = rememberCoroutineScope()
// Refresh log list
fun refresh() {
logFiles = LogUtil.getLogFiles()
if (selectedFile != null && selectedFile !in logFiles) {
selectedFile = null
logContent = emptyList()
}
}
LaunchedEffect(Unit) { refresh() }
// SAF export launcher
val exportLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/plain")
) { uri ->
if (uri != null && selectedFile != null) {
exportLogFile(context, uri, selectedFile!!)
}
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
// ── Header ──
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text("运行日志", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
IconButton(onClick = { refresh() }) {
Icon(Icons.Filled.Refresh, contentDescription = "刷新")
}
}
if (logFiles.isEmpty()) {
Text(
"暂无日志文件",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 24.dp)
)
}
// ── Log file list ──
Text("日志文件", style = MaterialTheme.typography.labelLarge)
LazyColumn(
modifier = Modifier.heightIn(max = 160.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(logFiles, key = { it.absolutePath }) { file ->
val isSelected = file == selectedFile
Card(
onClick = {
selectedFile = file
scope.launch {
logContent = withContext(Dispatchers.IO) {
file.readLines()
}
}
},
colors = CardDefaults.cardColors(
containerColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = file.name,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)
)
Text(
text = "${file.length() / 1024}KB",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
Spacer(Modifier.height(12.dp))
// ── Action buttons ──
if (selectedFile != null) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = { exportLauncher.launch(selectedFile!!.name) },
modifier = Modifier.weight(1f)
) {
Icon(Icons.Filled.FileDownload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(4.dp))
Text("导出")
}
OutlinedButton(
onClick = {
selectedFile!!.delete()
refresh()
},
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
modifier = Modifier.weight(1f)
) {
Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(4.dp))
Text("删除")
}
}
Spacer(Modifier.height(8.dp))
// ── Log content ──
Text(
"日志内容 — ${selectedFile!!.name}",
style = MaterialTheme.typography.labelLarge
)
Surface(
modifier = Modifier.fillMaxWidth().weight(1f),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small
) {
if (logContent.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("(空)", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(8.dp)
) {
// Show last 500 lines (newest at bottom)
val displayLines = logContent.takeLast(500)
for (line in displayLines) {
Text(
text = line,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
private fun exportLogFile(context: Context, uri: Uri, file: File) {
try {
context.contentResolver.openOutputStream(uri)?.use { out ->
file.inputStream().use { `in` ->
`in`.copyTo(out)
}
}
} catch (e: Exception) {
Log.e("LogScreen", "导出日志失败", e)
}
}

View File

@@ -1,5 +1,7 @@
package com.example.androidbackupgui.ui
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -10,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.example.androidbackupgui.backup.*
import com.example.androidbackupgui.backup.defaultResticWrapper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -34,6 +37,29 @@ fun RestoreScreen() {
var availableSnapshots by remember { mutableStateOf<List<ResticWrapper.ResticSnapshot>>(emptyList()) }
val configFile = remember { File(context.filesDir, "backup_settings.conf") }
// SAF directory picker for selecting external backup dir
val dirPickerLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { uri ->
if (uri != null) {
val resolvedPath = resolveSafTreeUri(uri)
if (resolvedPath != null) {
val dir = File(resolvedPath)
backupDir = dir
selectedSnapshot = null
scope.launch {
loadFromDir(context, dir) { pkgs, infos, status ->
packages = pkgs
appInfos = infos
selectedPackages = pkgs.toSet()
statusText = status
}
}
}
}
}
// Load config
LaunchedEffect(Unit) {
config = BackupConfig.fromFile(configFile)
@@ -46,7 +72,6 @@ fun RestoreScreen() {
// ── Top controls card ──
Card(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Source buttons row
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
@@ -54,17 +79,20 @@ fun RestoreScreen() {
scope.launch {
try {
val defaultDir = context.filesDir
val backupDirs = withContext(Dispatchers.IO) {
defaultDir.listFiles()
?.filter { it.isDirectory && it.name.startsWith("Backup_") }
?: emptyList()
}
val backupDirs =
withContext(Dispatchers.IO) {
defaultDir
.listFiles()
?.filter { it.isDirectory && it.name.startsWith("Backup_") }
?: emptyList()
}
if (backupDirs.isNotEmpty()) {
val dir = backupDirs.first()
backupDir = dir
selectedSnapshot = null
loadFromDir(context, dir) { pkgs, infos, status ->
packages = pkgs; appInfos = infos
packages = pkgs
appInfos = infos
selectedPackages = pkgs.toSet()
statusText = status
}
@@ -77,31 +105,54 @@ fun RestoreScreen() {
}
},
enabled = !isRunning,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
) {
Text("本地备份")
}
OutlinedButton(
onClick = { dirPickerLauncher.launch(null) },
enabled = !isRunning,
modifier = Modifier.weight(1f),
) {
Text("选择目录")
}
Button(
onClick = {
val config = resticConfig ?: run {
statusText = "未配置 Restic请先在设置中配置"
return@Button
}
val config =
resticConfig ?: run {
statusText = "未配置 Restic请先在设置中配置"
return@Button
}
scope.launch {
isRunning = true
statusText = "正在读取快照…"
try {
val result = withContext(Dispatchers.IO) {
ResticWrapper.listSnapshots(
config.resticRepo, config.resticPassword,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
)
}
// 配置 ResticWrapper 环境
defaultResticWrapper.cacheDir = context.cacheDir.absolutePath
defaultResticWrapper.backendDomain = config.resticBackendDomain
ResticBinary.prepare(context)?.let { defaultResticWrapper.binaryPath = it }
// 从 PasswordManager 恢复密码(过滤掉占位符)
fun configPw(
key: String?,
fallback: String,
): String = key?.takeIf { it.isNotEmpty() && it != "stored-in-keystore" } ?: fallback
val realPassword = configPw(PasswordManager.getResticPassword(), config.resticPassword)
val realBackendPass = configPw(PasswordManager.getBackendPass(), config.resticBackendPass)
val result =
withContext(Dispatchers.IO) {
defaultResticWrapper.listSnapshots(
config.resticRepo,
realPassword,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = realBackendPass,
backendShare = config.resticBackendShare,
)
}
if (result.isFailure) {
statusText = "读取快照失败: ${result.exceptionOrNull()?.message}"
return@launch
@@ -114,9 +165,12 @@ fun RestoreScreen() {
availableSnapshots = snaps
if (snaps.size == 1) {
loadResticSnapshot(context, snaps.first(), resticConfig!!) { pkgs, infos, status ->
backupDir = null; selectedSnapshot = snaps.first()
packages = pkgs; appInfos = infos
selectedPackages = pkgs.toSet(); statusText = status
backupDir = null
selectedSnapshot = snaps.first()
packages = pkgs
appInfos = infos
selectedPackages = pkgs.toSet()
statusText = status
}
} else {
showSnapshotPicker = true
@@ -129,21 +183,26 @@ fun RestoreScreen() {
}
},
enabled = !isRunning && resticConfig != null,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
) {
Text("Restic 快照")
}
}
// Source info text
val sourceText = if (backupDir != null) backupDir!!.absolutePath
else if (selectedSnapshot != null) "restic: ${selectedSnapshot!!.time.take(19)}"
else ""
val sourceText =
if (backupDir != null) {
backupDir!!.absolutePath
} else if (selectedSnapshot != null) {
"restic: ${selectedSnapshot!!.time.take(19)}"
} else {
""
}
if (sourceText.isNotEmpty()) {
Text(
text = sourceText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -154,46 +213,54 @@ fun RestoreScreen() {
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
// ── App list ──
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(appInfos, key = { it.packageName.value }) { app ->
Card(
onClick = {
val pkg = app.packageName.value
selectedPackages = if (pkg in selectedPackages) selectedPackages - pkg
else selectedPackages + pkg
selectedPackages =
if (pkg in selectedPackages) {
selectedPackages - pkg
} else {
selectedPackages + pkg
}
},
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = app.packageName.value in selectedPackages,
onCheckedChange = { checked ->
val pkg = app.packageName.value
selectedPackages = if (checked) selectedPackages + pkg
else selectedPackages - pkg
}
selectedPackages =
if (checked) {
selectedPackages + pkg
} else {
selectedPackages - pkg
}
},
)
Spacer(Modifier.width(8.dp))
Column {
Text(
text = app.label.ifEmpty { app.packageName.value },
style = MaterialTheme.typography.bodyLarge
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = app.packageName.value,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -221,19 +288,26 @@ fun RestoreScreen() {
try {
statusText = "正在从 restic 快照恢复…"
val restoreResult = withContext(Dispatchers.IO) {
ResticWrapper.restore(
repoPath = config.resticRepo,
password = config.resticPassword,
snapshotId = snapshot.id,
targetPath = staging.absolutePath,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
)
}
val restoreResult =
withContext(Dispatchers.IO) {
val rPw =
PasswordManager.getResticPassword()?.takeIf { it != "stored-in-keystore" }
?: config.resticPassword
val rBpw =
PasswordManager.getBackendPass()?.takeIf { it != "stored-in-keystore" }
?: config.resticBackendPass
defaultResticWrapper.restore(
repoPath = config.resticRepo,
password = rPw,
snapshotId = snapshot.id,
targetPath = staging.absolutePath,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = rBpw,
backendShare = config.resticBackendShare,
)
}
if (restoreResult.isFailure) {
statusText = "restic 恢复失败: ${restoreResult.exceptionOrNull()?.message}"
return@launch
@@ -241,45 +315,54 @@ fun RestoreScreen() {
val restoredDir = File(staging, backupPath.removePrefix("/"))
statusText = "正在从恢复的备份安装应用…"
val result = withContext(Dispatchers.IO) {
val result =
withContext(Dispatchers.IO) {
RestoreOperation.restoreApps(
context = context,
backupDir = restoredDir,
userId = config.backupUserId.toString(),
filterPkgs = selectedPackages,
onProgress = { progress ->
statusText =
"[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
},
)
}
WifiManager.restore(restoredDir)
statusText =
buildString {
appendLine("恢复完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
append("耗时: ${result.elapsedMs / 1000}")
}
} finally {
try {
staging.deleteRecursively()
} catch (_: Exception) {
}
}
} else if (backupDir != null) {
val dir = backupDir!!
val result =
withContext(Dispatchers.IO) {
RestoreOperation.restoreApps(
context = context,
backupDir = restoredDir,
backupDir = dir,
userId = config.backupUserId.toString(),
filterPkgs = selectedPackages,
onProgress = { progress ->
statusText = "[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
}
statusText =
"[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
},
)
}
WifiManager.restore(restoredDir)
statusText = buildString {
WifiManager.restore(dir)
statusText =
buildString {
appendLine("恢复完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
append("耗时: ${result.elapsedMs / 1000}")
}
} finally {
try { staging.deleteRecursively() } catch (_: Exception) {}
}
} else if (backupDir != null) {
val dir = backupDir!!
val result = withContext(Dispatchers.IO) {
RestoreOperation.restoreApps(
context = context,
backupDir = dir,
userId = config.backupUserId.toString(),
filterPkgs = selectedPackages,
onProgress = { progress ->
statusText = "[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
}
)
}
WifiManager.restore(dir)
statusText = buildString {
appendLine("恢复完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
append("耗时: ${result.elapsedMs / 1000}")
}
}
} catch (e: Exception) {
statusText = "恢复异常: ${e.message}"
@@ -289,7 +372,7 @@ fun RestoreScreen() {
}
},
enabled = !isRunning && selectedPackages.isNotEmpty() && (backupDir != null || selectedSnapshot != null),
modifier = Modifier.fillMaxWidth().padding(12.dp)
modifier = Modifier.fillMaxWidth().padding(12.dp),
) {
if (isRunning) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
@@ -314,59 +397,69 @@ fun RestoreScreen() {
showSnapshotPicker = false
scope.launch {
loadResticSnapshot(context, snap, resticConfig!!) { pkgs, infos, status ->
backupDir = null; selectedSnapshot = snap
packages = pkgs; appInfos = infos
selectedPackages = pkgs.toSet(); statusText = status
backupDir = null
selectedSnapshot = snap
packages = pkgs
appInfos = infos
selectedPackages = pkgs.toSet()
statusText = status
}
}
},
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
) { Text(label) }
}
}
},
confirmButton = {
TextButton(onClick = { showSnapshotPicker = false }) { Text("取消") }
}
},
)
}
}
// ── Sub-composables ──
// ── Helper functions ──
private suspend fun loadFromDir(
context: android.content.Context,
dir: File,
onResult: (packages: List<String>, appInfos: List<AppInfo>, status: String) -> Unit
onResult: (packages: List<String>, appInfos: List<AppInfo>, status: String) -> Unit,
) {
withContext(Dispatchers.IO) {
val appListFile = File(dir, "appList.txt")
val pkgs = if (appListFile.exists()) {
appListFile.readLines()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
} else {
dir.listFiles()
?.filter { it.isDirectory }
?.map { it.name }
?: emptyList()
}
val pkgs =
BackupOperation.readTextFile(appListFile)?.let { content ->
content.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
} ?: run {
BackupOperation.listBackupFiles(dir)
?: emptyList()
}
// Filter to only apps that have actual backup data (at least one APK)
val validPkgs =
pkgs.filter { pkg ->
val appDir = File(dir, pkg)
val files = BackupOperation.listBackupFiles(appDir)
files?.any { it.endsWith(".apk") } == true
}
val skipped = pkgs.size - validPkgs.size
// Read cached labels from app_details.json (includes uninstalled apps)
val cachedLabels = readLocalAppDetails(dir)
val preLabeled = pkgs.map { pkg ->
AppInfo(packageName = PackageName(pkg), label = cachedLabels[pkg] ?: "")
}
val preLabeled =
validPkgs.map { pkg ->
AppInfo(packageName = PackageName(pkg), label = cachedLabels[pkg] ?: "")
}
// Resolve labels for currently installed apps, keep cached labels for uninstalled
val resolved = AppScanner.resolveLabels(context, preLabeled)
// For apps that resolveLabels fell back to package name, restore cached label
val infos = resolved.map { app ->
val cachedLabel = cachedLabels[app.packageName.value]
if (cachedLabel != null && app.label == app.packageName.value) app.copy(label = cachedLabel)
else app
}
onResult(pkgs, infos, "${pkgs.size} 个备份应用")
val infos =
resolved.map { app ->
val cachedLabel = cachedLabels[app.packageName.value]
if (cachedLabel != null && app.label == app.packageName.value) {
app.copy(label = cachedLabel)
} else {
app
}
}
val suffix = if (skipped > 0) "${skipped}个应用备份数据缺失已自动跳过)" else ""
onResult(validPkgs, infos, "${validPkgs.size} 个备份应用$suffix")
}
}
@@ -374,71 +467,126 @@ private suspend fun loadResticSnapshot(
context: android.content.Context,
snapshot: ResticWrapper.ResticSnapshot,
config: BackupConfig,
onResult: (packages: List<String>, appInfos: List<AppInfo>, status: String) -> Unit
onResult: (packages: List<String>, appInfos: List<AppInfo>, status: String) -> Unit,
) {
val backupPath = snapshot.paths.firstOrNull() ?: run {
onResult(emptyList(), emptyList(), "快照中找不到备份路径")
return
}
val dumpResult = ResticWrapper.dump(
config.resticRepo, config.resticPassword,
snapshot.id, "$backupPath/appList.txt",
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
)
val content = dumpResult.getOrNull()
val backupPath =
snapshot.paths.firstOrNull() ?: run {
onResult(emptyList(), emptyList(), "快照中找不到备份路径")
return
}
fun rp(
key: String?,
fallback: String,
) = key?.takeIf { it.isNotEmpty() && it != "stored-in-keystore" } ?: fallback
val realPassword = rp(PasswordManager.getResticPassword(), config.resticPassword)
val realBackendPass = rp(PasswordManager.getBackendPass(), config.resticBackendPass)
suspend fun tryDump(path: String) =
defaultResticWrapper
.dump(
config.resticRepo,
realPassword,
snapshot.id,
path,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = realBackendPass,
backendShare = config.resticBackendShare,
).getOrNull()
// 兼容流式备份新版根目录旧版meta/)和普通备份
val content =
tryDump("$backupPath/appList.txt")
?: tryDump("$backupPath/meta/appList.txt")
if (content == null) {
onResult(emptyList(), emptyList(), "无法从快照读取应用列表")
return
}
val pkgs = content.lines()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
val pkgs =
content
.lines()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
// Read cached labels from app_details.json in the snapshot
val cachedLabels = loadResticAppDetails(config, snapshot.id, backupPath)
val preLabeled = pkgs.map { pkg ->
AppInfo(packageName = PackageName(pkg), label = cachedLabels[pkg] ?: "")
}
val preLabeled =
pkgs.map { pkg ->
AppInfo(packageName = PackageName(pkg), label = cachedLabels[pkg] ?: "")
}
val resolved = AppScanner.resolveLabels(context, preLabeled)
val infos = resolved.map { app ->
val cachedLabel = cachedLabels[app.packageName.value]
if (cachedLabel != null && app.label == app.packageName.value) app.copy(label = cachedLabel)
else app
}
val infos =
resolved.map { app ->
val cachedLabel = cachedLabels[app.packageName.value]
if (cachedLabel != null && app.label == app.packageName.value) {
app.copy(label = cachedLabel)
} else {
app
}
}
onResult(pkgs, infos, "restic 快照共 ${pkgs.size} 个应用")
}
/** Read app_details.json from a local backup directory and return a package→label map. */
private suspend fun readLocalAppDetails(dir: File): Map<String, String> = withContext(Dispatchers.IO) {
val metaFile = File(dir, "app_details.json")
if (!metaFile.exists()) return@withContext emptyMap()
try {
val json = metaFile.readText()
ResticWrapper.parseAppDetailsJson(json).mapValues { it.value.label }
} catch (_: Exception) { emptyMap() }
}
private suspend fun readLocalAppDetails(dir: File): Map<String, String> =
withContext(Dispatchers.IO) {
val metaFile = File(dir, "app_details.json")
val json = BackupOperation.readTextFile(metaFile) ?: return@withContext emptyMap()
try {
defaultResticWrapper.parseAppDetailsJson(json).mapValues { it.value.label }
} catch (_: Exception) {
emptyMap()
}
}
/** Dump app_details.json from a restic snapshot and return a package→label map. */
private suspend fun loadResticAppDetails(
config: BackupConfig,
snapshotId: String,
backupPath: String
backupPath: String,
): Map<String, String> {
val dumpResult = ResticWrapper.dump(
config.resticRepo, config.resticPassword,
snapshotId, "$backupPath/app_details.json",
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
)
val json = dumpResult.getOrNull() ?: return emptyMap()
fun rp2(
key: String?,
fallback: String,
) = key?.takeIf { it.isNotEmpty() && it != "stored-in-keystore" } ?: fallback
val realPassword = rp2(PasswordManager.getResticPassword(), config.resticPassword)
val realBackendPass = rp2(PasswordManager.getBackendPass(), config.resticBackendPass)
suspend fun tryDump(path: String) =
defaultResticWrapper
.dump(
config.resticRepo,
realPassword,
snapshotId,
path,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = realBackendPass,
backendShare = config.resticBackendShare,
).getOrNull()
val json =
tryDump("$backupPath/app_details.json")
?: tryDump("$backupPath/meta/app_details.json")
?: return emptyMap()
return try {
ResticWrapper.parseAppDetailsJson(json).mapValues { it.value.label }
} catch (_: Exception) { emptyMap() }
defaultResticWrapper.parseAppDetailsJson(json).mapValues { it.value.label }
} catch (_: Exception) {
emptyMap()
}
}
/** Convert SAF tree URI to a filesystem path. */
private fun resolveSafTreeUri(uri: Uri): String? {
val docId = uri.lastPathSegment?.let { java.net.URLDecoder.decode(it, "UTF-8") } ?: return null
val colonIdx = docId.indexOf(':')
if (colonIdx < 0) return null
val storageId = docId.substring(0, colonIdx)
val relPath = docId.substring(colonIdx + 1).trim('/')
return if (storageId.equals("primary", ignoreCase = true)) {
"/storage/emulated/0/$relPath"
} else {
"/storage/$storageId/$relPath"
}
}

View File

@@ -0,0 +1,88 @@
package com.example.androidbackupgui.backup
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
class AppResultTest :
FunSpec({
context("AppResult.Success") {
test("holds value correctly") {
val result: AppResult<String> = AppResult.Success("hello")
result.isSuccess shouldBe true
result.isFailure shouldBe false
result.getOrNull() shouldBe "hello"
result.getOrDefault("default") shouldBe "hello"
}
test("fold maps success branch") {
val result: AppResult<Int> = AppResult.Success(42)
val output = result.fold({ it * 2 }, { -1 })
output shouldBe 84
}
test("map transforms value") {
val result = AppResult.Success(42)
val mapped = result.map { it.toString() }
mapped.shouldBeInstanceOf<AppResult.Success<String>>()
mapped.getOrNull() shouldBe "42"
}
test("getOrThrow returns value") {
val result = AppResult.Success(99)
result.getOrThrow() shouldBe 99
}
}
context("AppResult.Failure") {
val error = AppError.Network("connection lost")
test("holds error correctly") {
val result: AppResult<Int> = AppResult.Failure(error)
result.isSuccess shouldBe false
result.isFailure shouldBe true
result.getOrNull().shouldBeNull()
result.getOrDefault(0) shouldBe 0
result.errorOrNull() shouldBe error
}
test("fold maps failure branch") {
val result: AppResult<Int> = AppResult.Failure(error)
val output = result.fold({ it }, { err -> -1 })
output shouldBe -1
}
test("map passes through failure") {
val result: AppResult<Int> = AppResult.Failure(error)
val mapped = result.map { it * 2 }
mapped.shouldBeInstanceOf<AppResult.Failure>()
mapped.errorOrNull() shouldBe error
}
test("getOrThrow throws") {
val result = AppResult.Failure(error)
shouldThrow<RuntimeException> { result.getOrThrow() }
}
test("mapError transforms the error") {
val result: AppResult<Int> = AppResult.Failure(error)
val mapped = result.mapError { AppError.Parse("wrapped: ${it.message}") }
mapped.shouldBeInstanceOf<AppResult.Failure>()
(mapped.errorOrNull() as? AppError.Parse)?.let {
it.message shouldBe "wrapped: connection lost"
}
}
}
context("err helper") {
test("creates Failure") {
val result = err<String>(AppError.Cancelled)
result.shouldBeInstanceOf<AppResult.Failure>()
result.errorOrNull() shouldBe AppError.Cancelled
}
}
})

View File

@@ -4,59 +4,41 @@ import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.io.File
class BackupConfigTest : FunSpec({
class BackupConfigTest :
FunSpec({
// Helper: write config to temp file, read it back
fun roundTrip(config: BackupConfig): BackupConfig {
val tmp = File.createTempFile("cfg_test", ".conf")
try {
BackupConfig.toFile(config, tmp)
return BackupConfig.fromFile(tmp)
} finally {
tmp.delete()
// Helper: write config to temp file, read it back
fun roundTrip(config: BackupConfig): BackupConfig {
val tmp = File.createTempFile("cfg_test", ".conf")
try {
BackupConfig.toFile(config, tmp)
return BackupConfig.fromFile(tmp)
} finally {
tmp.delete()
}
}
}
test("plain password survives round trip") {
val c = BackupConfig(resticPassword = "simple123")
roundTrip(c).resticPassword shouldBe "simple123"
}
test("password is stored as placeholder (actual password in PasswordManager)") {
val c = BackupConfig(resticPassword = "simple123")
// Password is no longer in config file; toFile writes "stored-in-keystore"
roundTrip(c).resticPassword shouldBe ""
}
test("password with double-quote survives round trip") {
val c = BackupConfig(resticPassword = "pa\"ss\"word")
roundTrip(c).resticPassword shouldBe "pa\"ss\"word"
}
test("backend pass is stored as placeholder (actual pass in PasswordManager)") {
val c = BackupConfig(resticBackendPass = "secret")
roundTrip(c).resticBackendPass shouldBe ""
}
test("password with backslash survives round trip") {
val c = BackupConfig(resticPassword = "p\\a\\ss")
roundTrip(c).resticPassword shouldBe "p\\a\\ss"
}
test("output path with spaces survives round trip") {
val c = BackupConfig(outputPath = "/sdcard/my backups/")
roundTrip(c).outputPath shouldBe "/sdcard/my backups/"
}
test("password with leading and trailing spaces survives round trip") {
val c = BackupConfig(resticPassword = " sp ace ")
roundTrip(c).resticPassword shouldBe " sp ace "
}
test("password with special shell characters survives round trip") {
val c = BackupConfig(resticPassword = "p@\$s#w!ord&")
roundTrip(c).resticPassword shouldBe "p@\$s#w!ord&"
}
test("restic_backend_pass with quote and backslash survives round trip") {
val c = BackupConfig(resticBackendPass = "a\\\"b")
roundTrip(c).resticBackendPass shouldBe "a\\\"b"
}
test("output path with spaces survives round trip") {
val c = BackupConfig(outputPath = "/sdcard/my backups/")
roundTrip(c).outputPath shouldBe "/sdcard/my backups/"
}
test("non-restic fields are unaffected") {
val c = BackupConfig(backupMode = 1, backupWifi = 0, compressionMethod = "zstd")
val out = roundTrip(c)
out.backupMode shouldBe 1
out.backupWifi shouldBe 0
out.compressionMethod shouldBe "zstd"
}
})
test("non-restic fields are unaffected") {
val c = BackupConfig(backupMode = 1, backupWifi = 0, compressionMethod = "zstd")
val out = roundTrip(c)
out.backupMode shouldBe 1
out.backupWifi shouldBe 0
out.compressionMethod shouldBe "zstd"
}
})

View File

@@ -0,0 +1,69 @@
package com.example.androidbackupgui.backup
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
class PackageNameTest :
FunSpec({
context("PackageName constructor validation") {
test("accepts valid package names") {
PackageName("com.example.app").value shouldBe "com.example.app"
PackageName("com.google.android.gms").value shouldBe "com.google.android.gms"
PackageName("a.b").value shouldBe "a.b"
PackageName("com.example.app_v2.test").value shouldBe "com.example.app_v2.test"
PackageName("org.koin.android").value shouldBe "org.koin.android"
}
test("rejects blank package names") {
shouldThrow<IllegalArgumentException> { PackageName("") }
shouldThrow<IllegalArgumentException> { PackageName(" ") }
}
test("rejects package names without dots") {
shouldThrow<IllegalArgumentException> { PackageName("simple") }
shouldThrow<IllegalArgumentException> { PackageName("no_dot_at_all") }
}
test("rejects package names with invalid characters") {
shouldThrow<IllegalArgumentException> { PackageName("com.example .app") }
shouldThrow<IllegalArgumentException> { PackageName("com.example/app") }
shouldThrow<IllegalArgumentException> { PackageName("com.example\napp") }
}
test("rejects package names starting with dot") {
shouldThrow<IllegalArgumentException> { PackageName(".com.example") }
}
test("rejects package names ending with dot") {
shouldThrow<IllegalArgumentException> { PackageName("com.example.") }
}
}
context("PackageName.safe") {
test("returns PackageName for valid input") {
PackageName.safe("com.example.app").shouldNotBeNull()
PackageName.safe("a.b").shouldNotBeNull()
}
test("returns null for invalid input instead of throwing") {
PackageName.safe("").shouldBeNull()
PackageName.safe("no_dots").shouldBeNull()
PackageName.safe("with space").shouldBeNull()
PackageName.safe("with/slash").shouldBeNull()
}
}
context("PackageName equality and toString") {
test("value equality works") {
PackageName("com.example.app") shouldBe PackageName("com.example.app")
}
test("toString returns the package name") {
PackageName("com.example.app").toString() shouldBe "com.example.app"
}
}
})