59 Commits
v1.2 ... 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
sakuradairong
818faefa86 release: v1.15
修复: 备份异常 EPERM (Operation not permitted) 导致整个备份中断
- BackupOperation: 检查 mkdirs 返回值,writeText 异常时优雅降级而非抛出
- BackupScreen: 增加异常完整堆栈记录和智能提示(EPERM/EACCES)
2026-06-08 14:18:28 +08:00
sakuradairong
a806768c8b chore: 添加安全策略文件以及 Markdown 格式的 Issue 模板
添加 SECURITY.md 用于报告安全漏洞;添加 .md 格式的 Issue 模板以提高 GitHub 社区资料兼容性

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:08:20 +08:00
sakuradairong
4e954d375e chore: 添加 Issue 模板选择器配置 2026-06-07 21:07:00 +08:00
sakuradairong
9e7e351193 chore: 添加社区健康文件 LICENSE/CODE_OF_CONDUCT/CONTRIBUTING/ISSUE_TEMPLATE/PR_TEMPLATE
添加 GPL-3.0 许可证、贡献者公约行为准则、贡献指南、Issue/PR 模板以完善 GitHub 社区健康度(28%→100%)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:05:39 +08:00
sakuradairong
cffa9a2b8a release: v1.14
修复 shell 转义/管道死锁/配置序列化缺陷,新增配置导出与 BackupConfig 单元测试

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 20:48:52 +08:00
RainySY
834f515e01 test: 新增 BackupConfig 读写往返单元测试,修复 gitignore 误排除 src/test 2026-06-07 20:41:30 +08:00
RainySY
949d13f1ea Merge pull request #1 from sakuradairong/fix/shell-escape-and-config-export
fix: 修复 shell 转义/管道死锁/配置序列化缺陷,新增配置导出
2026-06-07 20:36:39 +08:00
RainySY
d701951338 fix: 修复 shell 转义/管道死锁/配置序列化缺陷,新增配置导出
- ResticCommandRunner: 用独立线程并发读取 stderr,消除非流式 runRestic 的管道死锁(stderr 缓冲区写满导致子进程与主线程互等,卡至 60s 超时)
- RestoreOperation.restoreSsaid: 对 ssaidValue 强制 hex 校验、id 强制 UUID 格式校验,避免在双引号 sed 表达式中被注入或写坏 settings_ssaid.xml(shellEscape 仅对单引号上下文有效)
- BackupConfig: 引号字段保留内部空格并对双引号/反斜杠做对称转义/反转义,修复含特殊字符或首尾空格的 restic 密码读写失真;兼容旧配置格式
- RootShell.configure: catch 范围扩到 Exception,异常 ROM 上不再崩溃启动
- ConfigScreen/ConfigViewModel: 新增配置导出(SAF CreateDocument),含明文密码时显示安全提示
2026-06-07 20:33:47 +08:00
sakuradairong
7743c35763 docs: 更新 README 匹配 Compose Material 3 UI 重构
- 更新架构图(Compose UI、REST 桥、Service 层)
- 更新功能列表(多用户配置、流式备份、快照管理)
- 更新版本历史表(v1.13-v1.3)
- 更新使用说明和远程后端配置表示例

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:07:09 +08:00
sakuradairong
f854569414 release: v1.13
- 更新版本号到 1.13 (versionCode 14)
- 更新 GitNexus 索引统计(1614 symbols / 4022 edges / 139 flows)
- 移除 viewBinding、无用依赖(fragment-ktx/viewpager2/constraintlayout)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:06:36 +08:00
sakuradairong
6c9c8fe1b8 feat: BackupConfig 添加 backupUserId 字段
将用户选择从 BackupScreen/RestoreScreen 迁移到配置页面。
backupUserId 持久化到 backup_settings.conf,默认 0 (Owner)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:06:23 +08:00
sakuradairong
4f97cf75b6 fix: 修复多项 bug 和清理残留资源
- RestBridgeRunner: SMB transport 缓存加入 password,修改密码后生效
- ResticCommandRunner: 提取 Process.waitForCompat 消除忙等待重复
- ResticRestBridge: buildV2Json 使用 kotlinx.serialization 防止 JSON 注入
- RestoreOperation: 移除过度重置的 appops reset
- RootShell: configure() 添加 try-catch 防止 libsu 先创建 shell 时崩溃
- colors.xml/themes.xml: 精简为最小定义(Compose Material 3 接管主题)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:05:41 +08:00
sakuradairong
f0ae32b3f9 feat: Compose Material 3 UI 重构
- 添加 Jetpack Compose 依赖(BOM 2024.02 + Material3)
- 创建 Material 3 主题系统(亮色/暗色)
- 实现 AppScaffold + BottomNavigation 导航框架
- 迁移 ConfigScreen/BackupScreen/RestoreScreen 到 Compose
- 使用 ConfigViewModel (StateFlow) 驱动配置 UI
- MainActivity 切换为 setContent Compose 入口
- 删除旧的 Fragment 和 XML 布局文件
- 清理无用资源文件(dimens/ids/icons/menu)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:05:08 +08:00
RainySY
7c780b30c0 feat: UI compact layout + unlock support + ResticBinary init at startup 2026-06-07 13:37:21 +08:00
sakuradairong
5faedd53af release: v1.13
- CRITICAL: 配置文件权限加固, 无障碍修复
- HIGH: CancellationException 透传 ×8, SMB/WebDAV Failure 修复, supervisorScope
- 构建: bind 127.0.0.1, allowBackup=false, CI test
- 安全: 签名密码加固, ResticRestBridge auth
- 死代码: 删除 MD4Provider, 3 个死方法, DataSizes, isFileNotFound, getAppLabel
- 修复: ResticCommandRunner NPE, MissingAlgoProvider 全局注册
- 网络: SMB/WebDAV 重试+退避, WebDAV Range 断点续传
- 稳定性: onDestroyView null-safety, isArchiveSafe symlink 误杀修复, WebDAV 超时配置
2026-06-06 13:09:23 +08:00
sakuradairong
1f3e1ceea8 release: v1.12
fix: HEAD /backup/config 响应 Content-Length 为 0,restic 以为 config 空文件
fix: 添加 RemoteTransport.fileSize() 方法,HEAD 返回实际文件大小
feat: SmbTransport/WebdavTransport 实现 fileSize
2026-06-06 01:07:12 +08:00
sakuradairong
3813f49a12 release: v1.11
fix: restic REST URI 含 repo 前缀 (/backup/config), 桥接器未剥离导致
     type/name 解析错误, remoteBase + URI 双重拼接产生路径嵌套

fix: 在 handleRequest 中剥离 repoPath 前缀后再解析 segments,
     使 type/name 指向正确的 restic REST API 资源 (config/keys/data/...)
     "backup" 段不会再被拼入 SMB 路径
2026-06-06 00:31:42 +08:00
sakuradairong
b2ea0c7960 release: v1.10
fix: config/blob GET handler 提前删文件导致 restic 读到零字节
fix: NanoHTTPD Response 在 handler 返回后才发送,finally 删除过早
fix: 改为 readBytes() 后关闭文件,再返回 InputStream
2026-06-06 00:25:20 +08:00
sakuradairong
058bf23465 release: v1.9
fix: streamBodyToFile 按 Content-Length 精确读取,防止 keep-alive 死锁
fix: NanoHTTPD inputStream 无 Content-Length 限制,copyTo 读到下一个请求
chore: bridge.start(0) 禁用 socket 超时(restic 密钥生成不限时)
chore: 移除 ConfigViewModel withTimeoutOrNull(由桥接器超时控制)
2026-06-06 00:18:09 +08:00
sakuradairong
7fec4c52a1 release: v1.8
fix: 桥接器 socket 超时 = 0(禁用),restic 密钥生成不限时
fix: 去掉应用层超时兜底,让 init 自然完成
feat: streamBodyToFile 添加耗时日志(可观察密钥生成耗时)
2026-06-06 00:01:23 +08:00
sakuradairong
32182b592e release: v1.7
chore: 桥接器超时 60s,应用层超时 60s(按用户反馈)
2026-06-05 23:55:25 +08:00
sakuradairong
bb7dc9a700 release: v1.6
fix: 彻底禁用桥接器 socket 超时(start(0)),restic 密钥生成在慢设备上可超过 5 分钟
fix: ConfigViewModel.initResticRepo 添加 15 分钟超时兜底
fix: SMB blob 上传校验大小一致性(重读远端文件验证)
fix: MissingAlgoProvider 合并 MD4 + AESCMAC 算法注入
fix: NanoHTTPD socket timeout = 0(无限超时)避免 blob 体读取中断
feat: ConfigViewModel initGuard 防重复初始化
feat: SMB 传输缓存复用避免跨桥接器认证重建
2026-06-05 23:53:28 +08:00
sakuradairong
b01569416d release: v1.5
- fix: 增加 NanoHTTPD 桥接器 socket 超时 10s→300s 修复 SMB 慢传输超时
- fix: streamBodyToFile 改用 Result<File>,报错时返回具体异常信息
- feat: SMB 传输缓存复用,避免跨桥接器 SMB 会话重建
- feat: MD4/AESCMAC 算法注入支持(jcifs-ng 兼容)
- feat: ResticRepoInit 退出码 1 区分仓库已存在和真实错误
- feat: ConfigViewModel initGuard 防重复初始化
- fix: SMB signing 默认关闭(兼容家庭 NAS)
- fix: SMB 上传后重读校验文件大小一致性
- fix: 仓库初始化成功后自动刷新状态
- build: 依赖 BouncyCastle bcprov(MD4)、jcifs-ng 排除 BouncyCastle
- build: ProGuard 保留 jcifs 反射调用类
- build: 签名配置修正 storeFile 路径 + v1/v2 签名启用
- refactor: ResticRestBridge 流式文件读取(避免 OOM)
2026-06-05 23:44:06 +08:00
sakuradairong
26823fcb6f fix: 修复 release 签名配置(移除 if 守卫使 storeFile 始终生效)
signingConfigs.release 的 storeFile/storePassword 之前放在 if (keystoreFile.exists()) 块内,
导致 Gradle 配置阶段有时跳过属性赋值,APK 构建后无 v1 签名(v2 签名正常工作)。
移除 if 守卫后签名配置始终完整设置。
2026-06-05 16:07:48 +08:00
sakuradairong
6f6549d897 chore: bump version to v1.4 (versionCode 5) 2026-06-05 15:58:39 +08:00
sakuradairong
c10505fc10 feat: APK 体积优化 v1.4 — R8 full mode + shrinkResources + 依赖裁剪
- 启用 release minifyEnabled + shrinkResources
- 排除 BouncyCastle PQC unused 文件(节省 1.18 MB)
- 排除 sardine-android 传递依赖 xpp3/stax(修复 R8 类型冲突)
- 添加 ProGuard keep 规则(kotlinx-serialization / NanoHTTPD / 数据类)
- 修正 NanoHTTPD keep 规则包路径(org.nanohttpd → fi.iki.elonen)
- 修正 AppError/AppResult keep 规则包路径(domain → backup)
- APK 体积: 25 MB → 11.8 MB(-52.8%)
- 更新 README:版本历史、体积优化说明、编译命令
2026-06-05 14:51:26 +08:00
sakuradairong
7e98e0f78e refactor: replace staging sync with REST bridge and add streaming backup
Phase 1: REST bridge
- New ResticRestBridge (NanoHTTPD) implementing restic REST protocol
- New RestBridgeRunner lifecycle manager (withBridge pattern)
- ResticEnvResolver: buildLocalEnv/buildBridgeEnv split
- ResticWrapper: syncManager → bridgeRunner, propagate cacheDir
- 5 sub-modules (Backup/Restore/SnapshotOps/Maintenance/RepoInit):
  unified local/bridge branching
- Delete RemoteSyncManager.kt (231 lines removed)
- Clean RemoteTransport companion (only create() remains)
- Remove getTempRepoDir, cleanup(), onSyncProgress/ByteSyncProgress

Phase 2: Streaming backup
- New StreamingBackup with FIFO orchestration
- ResticBackup.backupStdin() with --stdin mode
- ResticCommandRunner.runResticWithStdin() (API 24 compat)
- BackupFragment: space detection + auto-switch to streaming

Code quality fixes:
- OOM protection: stream blob bodies to temp files
- API compat: manual stdin piping (no redirectInput API 26)
- Build: 0 errors, 0 warnings, lint 0 errors, all 53 tests pass
2026-06-05 14:11:52 +08:00
sakuradairong
922a8f0381 feat: cumulative snapshots, per-app data exclusion, clickable rows
Round 4:
- Clickable app rows: tapping anywhere on row toggles selection
- Per-app data exclusion: exclude data backup per app
- Backup path display on backup screen

Round 5:
- Cumulative snapshots: every restic snapshot contains all apps ever backed up
- Automatic merge of historical snapshot apps with current selection
- Removed incremental/full dialog — no user choice needed, always safe
- Legacy metadata preserved via buildAppDetailsJson(legacyApps)
2026-06-04 22:59:11 +08:00
sakuradairong
5fcf261025 chore: bump version to 1.3 2026-06-04 22:57:39 +08:00
99 changed files with 12874 additions and 4152 deletions

33
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,33 @@
---
name: Bug 报告
about: 报告一个 Bug 帮助我们改进
title: '[Bug] '
labels: bug
assignees: ''
---
**问题描述**
清晰简洁地描述这个 Bug。
**复现步骤**
1. 打开应用...
2. 点击...
3. 看到错误...
**预期行为**
您期望发生什么。
**实际行为**
实际发生了什么。
**截图/日志**
如有错误截图或 logcat 输出,请附在此处。
**环境信息**
- 应用版本: [如 v1.14]
- 设备型号: [如 Pixel 6]
- Android 版本: [如 Android 14]
- Root 方案: [Magisk / KernelSU / APatch / 无]
**其他信息**
任何可能有助于诊断问题的其他信息。

99
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,99 @@
name: Bug 报告
description: 报告一个 Bug 帮助我们改进
title: "[Bug] "
labels: [bug]
body:
- type: markdown
attributes:
value: |
感谢您花时间报告这个问题!请尽可能详细地填写以下信息。
- type: textarea
id: description
attributes:
label: 问题描述
description: 清晰简洁地描述这个 Bug 是什么
validations:
required: true
- type: textarea
id: steps
attributes:
label: 复现步骤
description: 复现该问题的具体步骤
placeholder: |
1. 打开应用...
2. 点击...
3. 看到错误...
validations:
required: true
- type: textarea
id: expected
attributes:
label: 预期行为
description: 您期望发生什么
validations:
required: true
- type: textarea
id: actual
attributes:
label: 实际行为
description: 实际发生了什么
validations:
required: true
- type: textarea
id: screenshot
attributes:
label: 截图/日志
description: 如有错误截图或 logcat 输出,请附在这里
validations:
required: false
- type: input
id: version
attributes:
label: 应用版本
description: 您正在使用的版本号(可在设置中查看)
placeholder: "v1.14"
validations:
required: true
- type: input
id: device
attributes:
label: 设备型号
placeholder: "Pixel 6 / Xiaomi 14"
validations:
required: true
- type: input
id: android
attributes:
label: Android 版本
placeholder: "Android 14"
validations:
required: true
- type: dropdown
id: root
attributes:
label: Root 方案
options:
- Magisk
- KernelSU
- APatch
- 其他
- 无 Root
validations:
required: true
- type: textarea
id: additional
attributes:
label: 其他信息
description: 任何可能有助于诊断问题的其他信息
validations:
required: false

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: 讨论区
url: https://github.com/sakuradairong/android-backup-gui/discussions
about: 如需提问或讨论功能,请使用 Discussion

View File

@@ -0,0 +1,19 @@
---
name: 功能请求
about: 为这个项目提一个新功能建议
title: '[Feature] '
labels: enhancement
assignees: ''
---
**使用场景**
这个功能解决了什么问题?在什么场景下需要?
**期望方案**
您期望的行为或交互方式是什么样的。
**替代方案**
您考虑过其他替代方案吗?
**附加信息**
如有截图、参考实现或其他上下文,请附在此处。

View File

@@ -0,0 +1,43 @@
name: 功能请求
description: 为这个项目提一个新功能建议
title: "[Feature] "
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
感谢您花时间提出改进建议!请描述您想要的功能。
- type: textarea
id: problem
attributes:
label: 使用场景
description: 这个功能解决了什么问题?在什么场景下需要?
placeholder: "当我使用...功能时,发现...不够方便"
validations:
required: true
- type: textarea
id: solution
attributes:
label: 期望方案
description: 您期望的行为或交互方式是什么样的
placeholder: "希望在某某页面添加一个...按钮,点击后..."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: 替代方案
description: 您考虑过其他替代方案吗?
validations:
required: false
- type: textarea
id: context
attributes:
label: 附加信息
description: 如有截图、参考实现或其他上下文,请附在这里
validations:
required: false

30
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,30 @@
## 变更摘要
<!-- 简要描述此 PR 的变更内容 -->
## 关联 Issue
<!-- 如果有,请关联相关的 Issue如 Fixes #123-->
## 变更类型
- [ ] Bug 修复
- [ ] 新功能
- [ ] 重构/代码优化
- [ ] 文档更新
- [ ] 测试
- [ ] 其他
## 测试清单
- [ ] `./gradlew lint` 通过
- [ ] `./gradlew test` 通过
- [ ] 已在真机/模拟器上测试
## 截图(如有 UI 变更)
<!-- UI 变更请附上前后对比截图 -->
## 其他说明
<!-- 任何需要 reviewer 了解的额外信息 -->

View File

@@ -24,6 +24,8 @@ jobs:
- name: Lint
run: ./gradlew lint
- name: Test
run: ./gradlew test
- name: Build release APK
run: ./gradlew assembleRelease

2
.gitignore vendored
View File

@@ -21,5 +21,5 @@ release.keystore
memory:*
# Restic test repository (contains encryption keys)
test/
/test/
kmboxnet

12
.omp/lsp.json Normal file
View File

@@ -0,0 +1,12 @@
{
"servers": {
"kotlin-lsp": {
"command": "kotlin-language-server",
"args": [],
"fileTypes": [".kt", ".kts"],
"rootMarkers": ["build.gradle", "settings.gradle"],
"warmupTimeoutMs": 60000
}
},
"idleTimeoutMs": 600000
}

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** (922 symbols, 2334 relationships, 79 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** (922 symbols, 2334 relationships, 79 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.

39
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,39 @@
# 贡献者公约行为准则
## 我们的承诺
为了营造一个开放、友好的环境,我们作为贡献者和维护者承诺:无论年龄、体型、残疾、种族、性别认同与表达、经验水平、国籍、个人外貌、种族、宗教或性取向如何,参与我们的项目和社区的每个人都不会受到骚扰。
## 我们的标准
有助于营造积极环境的行为包括:
- 使用友好和包容的语言
- 尊重不同的观点和经验
- 优雅地接受建设性批评
- 关注对社区最有利的事情
- 对其他社区成员表示同理心
不可接受的行为包括:
- 使用带有性暗示的语言或图像,以及不受欢迎的性关注或挑逗
- 挑衅、侮辱/贬损性评论,以及人身或政治攻击
- 公开或私下骚扰
- 未经明确许可发布他人的私人信息(如地址或电子邮件)
- 在专业环境中可能被合理认为不合适的其他行为
## 我们的责任
项目维护者有责任明确可接受行为的标准,并应对任何不可接受行为采取适当和公平的纠正措施。
## 适用范围
本行为准则适用于项目空间和公共空间,当个人代表项目或其社区时同样适用。
## 执行
如有滥用、骚扰或其他不可接受行为,请联系项目团队。所有投诉将得到审查和调查,并将给出必要且适当的回应。
## 归属
本行为准则改编自 [Contributor Covenant](https://www.contributor-covenant.org) 2.1 版,可在 https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 查看。

103
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,103 @@
# 贡献指南
感谢您对 **Android Backup GUI** 的关注!欢迎通过 Issue 和 Pull Request 参与贡献。
## 目录
- [开发环境](#开发环境)
- [构建项目](#构建项目)
- [提交 Issue](#提交-issue)
- [提交 Pull Request](#提交-pull-request)
- [代码风格](#代码风格)
## 开发环境
- **JDK**: 17+
- **Android SDK**: API 34targetSdk 34, minSdk 24
- **IDE**: Android Studio Hedgehog+ 或 IntelliJ IDEA
- **Gradle**: 8.2(通过 Gradle Wrapper 自动使用)
### 首次构建
```bash
# 克隆仓库
git clone https://github.com/sakuradairong/android-backup-gui.git
cd android-backup-gui
# 确认 Android SDK 路径(创建 local.properties 如果不存在)
echo "sdk.dir=/path/to/Android/Sdk" > local.properties
# 构建 debug APK
./gradlew assembleDebug
```
## 构建项目
```bash
# 运行 lint 检查
./gradlew lint
# 运行单元测试
./gradlew test
# 构建 release APK需配置签名
./gradlew assembleRelease
```
> **注意**: Release 构建需要 `app/release.keystore` 文件,以及 `KEYSTORE_PASSWORD` 和 `KEY_PASSWORD` 环境变量。开发调试请使用 `assembleDebug`。
## 提交 Issue
### Bug 报告
请确保包含以下信息:
1. **设备信息**: Android 版本、设备型号、是否 root
2. **环境**: restic 版本如有、root 方案Magisk / KernelSU / APatch
3. **复现步骤**: 详细的操作步骤
4. **预期行为**: 您期望发生什么
5. **实际行为**: 实际发生了什么
6. **日志**: 相关的 logcat 输出或错误截图
### 功能请求
请说明:
1. **使用场景**: 这个功能解决什么问题
2. **期望方案**: 期望的行为或交互方式
3. **替代方案**: 您考虑过的其他方案
## 提交 Pull Request
1. **Fork** 本仓库
2. 创建功能分支: `git checkout -b feature/your-feature`
3. **确保代码通过 lint 和测试**:
```bash
./gradlew lint test
```
4. 提交变更:
```bash
git commit -m "feat: 简洁描述变更内容"
```
5. 推送到您的 Fork: `git push origin feature/your-feature`
6. 创建 Pull Request 到 `main` 分支
### PR 要求
- 每个 PR 专注于一个功能或修复
- 提交信息遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范
- 新增功能应包含对应的单元测试
- UI 变更请在 PR 描述中附上截图
- 确保 `./gradlew lint test` 通过
## 代码风格
- 使用 Kotlin 官方代码风格Kotlin Coding Conventions
- 使用 `ktlint` 检查代码格式(`./gradlew lint` 包含)
- compose 相关代码遵循 Jetpack Compose 编码规范
- 命名使用直观的英文(不推荐拼音)
- 对复杂逻辑编写简明注释
## 许可
贡献即表示您同意您的贡献将在 [GPL-3.0](LICENSE) 许可下发布。

675
LICENSE Normal file
View File

@@ -0,0 +1,675 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

204
README.md
View File

@@ -1,129 +1,155 @@
# Android Backup GUI
Android 应用备份与恢复工具,集成 [restic](https://restic.net/) 实现增量去重备份,支持 WebDAV / SMB 远程仓库。
Android 应用备份与恢复工具,通过 **root 权限** 实现应用的完整备份APK + 用户数据 + OBB + SSAID + 权限 + WiFi 配置),集成 [restic](https://restic.net/) 实现增量去重备份,支持 SMB / WebDAV 远程仓库。
## 功能
- **应用扫描** — 自动列出第三方应用,支持系统应用白名单
- **APK + 数据备份** — 备份 APK 文件、应用数据目录、OBB 数据、SSAID、权限、WiFi 配置
- **并行备份/恢复** — 备份并发数 3Semaphore(3)),恢复并发数 2Semaphore(2)
- **存档完整性校验** — 备份后自动 zstd/gzip 校验数据归档
- **restic 增量去重** — 内建 `librestic.so`~24MB支持本地和远端仓库
- **远程后端** — WebDAV如 123 云盘)/ SMB 协议,本地临时仓库 + 自动双向同步 + 进度回调
- **配置持久化** — 仓库路径、密码、后端参数保存在 `backup_settings.conf`
- **快照管理** — 初始化仓库、查看统计、按策略清理旧快照(保留 7 天/4 周/3 月)
- **应用名显示** — 使用 `PackageManager` 解析应用名,优先显示中文名,回退包名
- **APK + 数据备份** — 备份 APK 文件、应用数据目录、OBB 数据、SSAID、SSAID 广告标识、AppOps 权限、WiFi 配置
- **多用户支持** — 从配置页选择 Android 用户(主用户 / 工作资料),持久化到配置文件
- **并行备份/恢复** — 备份并发 3Semaphore恢复并发 2Semaphore
- **存档完整性校验** — 备份后自动 zstd/gzip 校验 + tar 结构验证
- **restic 增量去重** — 内建 `librestic.so`~24MBSSD 加密快照,增量备份
- **远程后端** — 本地 REST 桥 + NanoHTTPD 将 SMB/WebDAV 协议翻译为 restic 可直接访问的 REST API
- **流式备份** — FIFO 管道对接 `restic backup --stdin`,无需本地暂存
- **配置持久化** — 仓库路径、密码、后端参数、目标用户保存在 `backup_settings.conf`
- **快照管理** — 初始化、查看统计、按策略清理旧快照(保留 7 天/4 周/3 月)、解锁
- **累积快照** — 从历史快照读取元数据,合并为增量累积备份
- **应用名显示** — 备份时缓存应用名称到 `app_details.json`,已卸载应用也显示中文名
## 技术栈
- Kotlin / Android SDK
- [kotlinx-serialization](https://github.com/Kotlin/kotlinx.serialization)JSON 解析,取代 org.json
- Coroutines + StateFlow面向状态架构
- Android ViewModel + lifecycle-runtime-ktx
- Kotlin / Android SDK (minSdk 24, targetSdk 34)
- **Jetpack Compose + Material 3** UI
- ViewModel + StateFlow + SharedFlow面向状态架构
- kotlinx-serializationJSON 解析)
- restic 0.17+ 二进制(编译为 `librestic.so`
- sardine-android (WebDAV 客户端)
- SMBJ (jcifs-ng) (SMB 客户端)
- Material 3 UI
- Root shell with Mutex + timeout (120s)
- jcifs-ng (SMB 客户端)
- NanoHTTPDREST 桥服务器)
- libsuMagisk / KernelSU / APatch root shell
- Kotest + MockK单元测试
## 架构
```
BackupFragment / RestoreFragment / ConfigFragment (UI)
│ viewModels() │ observe StateFlow
ConfigViewModel ResticWrapper
└─ StateFlow<ConfigUiState> ├── ResticBackup (备份)
├── ResticRestore (恢复 + dump)
├── ResticSnapshotOps (快照列表)
├── ResticMaintenance (forget/prune/check/stats)
├── ResticRepoInit (init)
├── ResticCommandRunner(进程执行)
├── ResticEnvResolver (环境变量)
├── RemoteSyncManager (同步编排)
── RemoteTransport (文件传输接口)
├── WebdavTransport (sardine, 8KB 分块)
└── SmbTransport (jcifs-ng, 8KB 分块 + 签名)
┌─────────────────────────────────────────────┐
│ UI 层 (Jetpack Compose + Material 3)
│ AppScaffold → BackupScreen / RestoreScreen │
/ ConfigScreen
│ / ConfigViewModel (StateFlow) │
├─────────────────────────────────────────────┤
业务逻辑层 (backup/) │
BackupOperation → root shell tar/cp │
│ RestoreOperation → root shell pm install │
StreamingBackup → FIFO pipe → restic │
│ ResticWrapper → facade 委托给: │
│ ├── ResticBackup (备份)
│ ├── ResticRestore (恢复 + dump) │
── ResticSnapshotOps (快照列表/forget) │
│ ├── ResticMaintenance (prune/check/stats) │
│ └── ResticRepoInit (init) │
│ ResticCommandRunner → ProcessBuilder │
│ ResticEnvResolver → 环境变量构建 │
│ RestBridgeRunner → ResticRestBridge │
│ RemoteTransport → file I/O 接口 │
│ ├── WebdavTransport (sardine, 8KB 分块) │
│ └── SmbTransport (jcifs-ng, 8KB 分块) │
├─────────────────────────────────────────────┤
│ Root 层 (root/) │
│ RootShell → libsu (Magisk/KernelSU/APatch) │
├─────────────────────────────────────────────┤
│ Service 层 │
│ BackupService → Foreground Service │
├─────────────────────────────────────────────┤
│ 原生二进制 (jniLibs) │
│ librestic.so / libtar_bin.so / libzstd_bin │
└─────────────────────────────────────────────┘
```
### 数据流
### 数据流(一次备份)
```
1. BackupOperation.backupApps() ─── 本地 APP 备份到 outputDir
2. WifiManager.backup(outputDir) WiFi 配置
3. ResticWrapper.backup(paths=[outputDir]) ─── restic 快照
├── RemoteSyncManager.withRemoteSync()
│ ├── syncFromRemote (WebDAV/SMB: 下载远端差异)
│ ├── action() (restic backup/restore 命令)
│ └── syncToRemote (WebDAV/SMB: 上传本地差异)
└── RemoteTransport
├── upload(download)
│ ├── onProgress(TransferProgress) ← 阶段 ("connecting" / "transferring" / "completed")
│ └── onByteProgress(ByteProgress) ← 8KB 粒度字节进度
└── protocol 实现: SmbTransport / WebdavTransport
用户选择应用 → 扫描 (AppScanner.enumerateUsers / scanThirdParty)
创建 Backup_ 目录 → 写入 appList.txt + app_details.json
并行 (Semaphore=3) 为每个应用:
├── 备份 APK (cp → app目录/包名.apk)
├── 备份数据 (tar zstd → 包名_data.tar.zst)
├── 备份 OBB (tar → 包名_obb.tar.zst)
├── 备份 SSAID (提取 → ssaid.txt)
├── 备份图标 (snapshot cache / aapt)
└── 备份权限 (dumpsys → permissions.txt)
WiFi 备份 → WifiManager.backup()
(可选) ResticWrapper.backup() → restic 快照到远程仓库
```
### restic 远程仓库流程
### 远程仓库REST 桥模式)
```
1. syncFromRemote: PROPFIND 递归列出远端文件 → 下载差异文件到本地临时仓库
→ 发出 TransferProgress("list") / TransferProgress("download") / TransferProgress("delete_stale")
2. runRestic: 在本地临时仓库执行 restic 命令 (backup / restore / snapshots / init ...)
3. syncToRemote: 递归遍历本地临时仓库 → 上传差异文件到远端
→ 发出 TransferProgress("upload") / TransferProgress("delete_stale") / TransferProgress("complete")
Restic CLI ←→ ResticRestBridge (NanoHTTPD, 127.0.0.1:random)
RemoteTransport (SMB/WebDAV)
```
远端同步基于内容大小比较,跳过同名等长文件;自动删除远端/本地过时文件
restic 通过 REST HTTP API 与本地桥通信,桥接器将请求翻译为 SMB/WebDAV 文件操作
无需本地 staging 仓库restic 直接读写远程存储。
### 关键设计
## 构建
- **导航栏索引** — 使用 `FragmentPagerAdapter` + 三个子 Fragment
- **href 自引用过滤** — WebDAV 服务器常将目录自身作为 PROPFIND 响应条目,通过比较资源 href 与请求 URL 精确过滤
- **根目录 404 保护** — 根目录返回 404 视为致命错误(防止限流导致误删本地文件),子目录 404 安全跳过
- **指数退避重试** — DNS 超时、5xx 错误、连接拒绝等瞬时故障自动重试1s/2s/4s最多 3 次
- **双向递归同步** — BFS 遍历远端目录树,深度限制 3 层,适配 restic 仓库结构
- **双向递归递归过滤** — 脏删除walkLocalFile filter适配临时仓库模式
- **SmbException 精确处理** — 区分 `STATUS_OBJECT_NAME_NOT_FOUND`(0xC0000034) / `STATUS_OBJECT_NAME_COLLISION`(0xC0000035)
- **标签解析** — `PackageManager.getApplicationLabel()` 批量解析,无 root 需求,比 `dumpsys package` 快 10x+
- **ConfigViewModel** — `StateFlow<ConfigUiState>` 驱动配置 UI`viewModelScope` 管理 restic 操作生命周期
- **进度回调线程安全** — TransferProgress 统一由 `withRemoteSync` 分派至 Main 线程ByteProgress 留在 IO8KB 粒度)
- **并发安全性** — `RootShell` 使用 `Mutex` + `withTimeout(120s)`;远程同步使用 `Mutex``BackupOperation`/`RestoreOperation` 使用 `Semaphore` + `AtomicInteger`
- **SMB 签名可选** — `smbSigning` 构造参数(默认 true兼容旧 SMB 服务器
- **文件大小限制** — WebDAV 上传 50MB 上限(防止 ByteArray OOM
- **存档完整性校验** — 备份后 zstd/gzip 验证数据归档,校验失败回告
### 版本历史
## 编译
| 版本 | 更新内容 |
|------|---------|
| v1.14 | 修复 shell 转义/管道死锁/配置序列化缺陷,新增配置导出与 BackupConfig 单元测试 |
| v1.13 | Compose Material 3 UI 重构、Unlock 支持、ResticBinary 启动初始化、修复 500 错误和刷新竞态 |
| v1.12 | 引擎 + Compose Material 3 UI 重构 |
| v1.11 | 构建系统改进、LSP 支持 |
| v1.10 | 后端桥接稳定性提升 |
| v1.9 | 远程后端优化 |
| v1.8 | 快照管理增强 |
| v1.7 | 多用户支持 |
| v1.6 | 累积快照 |
| v1.5 | 修复签名配置 |
| v1.4 | APK 体积优化R8 + shrinkResources25MB → 11.8MB |
| v1.3 | 累积快照、AppResult 类型化错误、kotlinx-serialization |
### 编译命令
```bash
# Debug APK
./gradlew assembleDebug
# Release (需配置签名)
./gradlew assembleRelease
# Release APK需配置签名
KEYSTORE_PASSWORD=<密码> KEY_PASSWORD=<密码> ./gradlew assembleRelease
```
`librestic.so` 需放在 `app/src/main/jniLibs/arm64-v8a/` 目录下,在 `build.gradle` 中禁用 `extractNativeLibs` 前的 `useLegacyPackaging`
> Release 构建需要 `app/release.keystore`;原生库放在 `jniLibs/arm64-v8a/`
## 使用说明
1. Android 设备需具备 **root 权限**用于 `pm path``dumpsys`、文件访问等
2. 在「置」页签配置 restic 仓库参数后端类型、URL、路径、密码
3. 点击「初始化」创建仓库(远程后端需 WebDAV/SMB 服务已运行
4. 在「备份」页签选择应用,点击「开始备份
5. 在「恢复」页签选择备份目录或 restic 快照,点击「开始恢复
1. Android 设备需 **root 权限**Magisk / KernelSU / APatch
2. 在「置」页签设置备份选项 + restic 仓库参数
3. 切换「备份用户」选项(多用户设备
4. 在「备份」页签选择应用开始备份
5. 在「恢复」页签选择本地备份或 restic 快照开始恢复
### WebDAV 配置示例
### 远程后端配置
| 字段 | |
|------|-----|
| 后端 | WebDAV |
| 地址 | `https://webdav.123pan.cn/webdav` |
| 用户名 | 手机号 |
| 密码 | 应用密码 |
| 仓库存放路径 | `back` |
| 字段 | WebDAV 示例 | SMB 示例 |
|------|-------------|----------|
| 后端 | WebDAV | SMB |
| 地址 | `https://webdav.example.com` | `192.168.1.165` |
| 用户名 | 账号 | Windows 用户名 |
| 密码 | 密码 | Windows 密码 |
| 共享名称 | — | `back` |
| 仓库存放路径 | `backup` | `backup` |
### 注意事项
- 应用卸载会清除 `backup_settings.conf`,建议定期导出配置
- Restic 仓库需先「初始化」才能使用(自动检测已有仓库)
- SMB 密码错误多次会导致 Windows 账户锁定,需在服务器上解锁

24
SECURITY.md Normal file
View File

@@ -0,0 +1,24 @@
# 安全策略
## 支持的版本
| 版本 | 支持状态 |
|--------|-------------------|
| v1.14 | ✅ 积极支持 |
| v1.13 | ✅ 积极支持 |
| < v1.13| ❌ 不再支持 |
## 报告安全漏洞
如果您发现安全漏洞,**请不要在 GitHub Issues 中公开披露**。请通过以下方式私下报告:
1. 在仓库中创建一个 [Security Advisory](https://github.com/sakuradairong/android-backup-gui/security/advisories/new)
2. 或发送邮件至(待设置,目前请使用 Security Advisory
我们会尽快确认并回应,通常在 **48 小时内**
### 安全注意事项
- 本应用需要 root 权限运行,请确保从可信来源下载 APK
- 备份数据使用 restic 加密存储,请妥善保管仓库密码
- 如发现敏感信息泄露,请立即通过 Security Advisory 联系我们

View File

@@ -0,0 +1,303 @@
# 无障碍审查报告 — Android Backup GUI
**审查阶段**:第三层 — 可维护性与用户体验
**审查技能**accessibility (WCAG 2.2)
**审查日期**2026-06-06
**项目规模**37 个源文件
**审查范围**UI 相关 4 个 Fragment/Activity、4 个布局 XML、菜单、资源文件
---
## 严重程度说明
| 级别 | 定义 |
|------|------|
| **严重** | 用户无法完成核心操作,或屏幕阅读器完全无法识别交互元素 |
| **高** | 严重阻碍无障碍使用,有合理的替代方案但未实现 |
| **中** | 影响使用体验,但用户可通过变通方式完成任务 |
| **低** | 体验可改进点,非阻塞性 |
---
## 发现汇总
| # | 文件 | 行号 | 问题 | 严重程度 |
|---|------|------|------|----------|
| 1 | PackageListAdapter.kt | 61-69, 116 | `TextView` 模拟按钮(排除数据切换)缺少无障碍角色 | **严重** |
| 2 | PackageListAdapter.kt | 76-88 | `MaterialCardView` 点击区域未合并无障碍语义 | **高** |
| 3 | PackageListAdapter.kt | 52-53 | `CheckBox` 缺少对应应用的 `contentDescription` | **高** |
| 4 | PackageListAdapter.kt | 109-115 | 排除数据切换状态未以文字方式通知 | **中** |
| 5 | fragment_backup.xml | 168-169 | statusText 缺少无障碍实时区域 | **高** |
| 6 | fragment_restore.xml | 90-91 | statusText 缺少无障碍实时区域 | **高** |
| 7 | fragment_config.xml | 384-390 | configStatusText 缺少无障碍实时区域 | **高** |
| 8 | fragment_backup.xml | 152-160 | progressBar 开始/结束状态无无障碍通知 | **中** |
| 9 | fragment_restore.xml | 73-81 | progressBar 开始/结束状态无无障碍通知 | **中** |
| 10 | fragment_backup.xml | 100-133 | 排序/全选按钮文本仅 11sp不符合可缩放要求 | **中** |
| 11 | fragment_backup.xml | 39-43 | "用户:" 标签与 Spinner 无程序化关联 | **中** |
| 12 | fragment_backup.xml | 59-65 | "输出目录:" 标签与目录显示无程序化关联 | **低** |
| 13 | fragment_restore.xml | 49-53 | "用户:" 标签与 Spinner 无程序化关联 | **中** |
| 14 | PackageListAdapter.kt | 61-69 | "数据"文本排除切换触摸目标可能不足 48dp | **中** |
| 15 | MainActivity.kt | 93-100 | BottomNavigation 缺少 `contentDescription` 仅凭图标导航 | **低** |
---
## 详细发现
### 发现 1`TextView` 模拟按钮 — 排除数据切换(严重)
**文件**`PackageListAdapter.kt`
**行号**61-69创建116点击监听器
**问题**:使用 `TextView`(非标准按钮控件)实现点击交互。`TextView` 默认不向 TalkBack 宣告其可点击角色。屏幕阅读器用户听到"数据"但不知道可以点击切换。
```kotlin
// 第 61-69 行:创建
val et = TextView(ctx).apply {
id = R.id.excludeToggle
// ...
}
// 第 116 行:添加点击
toggle.setOnClickListener {
dataToggleCb(pkg, !excluded)
}
```
**修复建议**
- 方案A(最优):改用 `MaterialButton``ImageButton` 替代 `TextView`,并设置 `contentDescription`
- 方案B(最小改动):在 `TextView` 上显式设置 `focusable = true``clickable = true`,并设置 `contentDescription` 说明其作用和状态。
- 添加 `stateDescription` 或更新 `contentDescription` 反映当前切换状态("点击排除数据备份" / "点击包含数据备份")。
---
### 发现 2卡片点击区域无障碍语义缺失(高)
**文件**`PackageListAdapter.kt`
**行号**76-88
**问题**`MaterialCardView` 上设置了 `setOnClickListener` 用于切换 CheckBox但 TalkBack 视卡片为一个独立可点击元素,未与内部 CheckBox 的角色合并。用户无法明确知道点击卡片的效果是切换选中状态。
```kotlin
// 第 76 行
card.setOnClickListener {
val pos = holder.adapterPosition
// ... 切换 checkbox
}
```
**修复建议**
- 为卡片设置 `contentDescription` 关联应用名和选中状态。
- 或为卡片添加 `role = Role.Button` 的语义,合并内部子元素的无障碍信息。
- 最佳实践是在 `onBindViewHolder` 中更新卡片的 `contentDescription` 为"勾选 ${app.label}"或"取消勾选 ${app.label}"。
---
### 发现 3CheckBox 缺少对应描述(高)
**文件**`PackageListAdapter.kt`
**行号**52-53创建92-102绑定
**问题**CheckBox 在代码中创建且未设置 `contentDescription`。虽然旁边有 `TextView` 显示应用名,但程序化关联不完善。
```kotlin
// 第 52-53 行
val cb = CheckBox(ctx).apply {
id = R.id.checkbox
// 没有 contentDescription
}
```
**修复建议**:在 `onBindViewHolder`(第 96 行设置 `textView.text` 之后)为 checkbox 设置 `contentDescription`
```kotlin
holder.checkbox.contentDescription = "选择 ${app.label.ifEmpty { pkg }}"
```
当选中/取消时同步更新描述。
---
### 发现 4排除数据切换状态缺少文字通知(中)
**文件**`PackageListAdapter.kt`
**行号**109-115
**问题**:排除数据开关通过 `paintFlags`(删除线)和 `isSelected` 来表示状态,这些仅视觉变化不会被 TalkBack 识别。用户无法知道当前是否已排除数据。
```kotlin
// 第 109-115 行
toggle.text = "数据"
toggle.paintFlags = if (excluded) {
toggle.paintFlags or android.graphics.Paint.STRIKE_THRU_TEXT_FLAG
} else {
toggle.paintFlags and android.graphics.Paint.STRIKE_THRU_TEXT_FLAG.inv()
}
toggle.isSelected = excluded
```
**修复建议**:显式更新 `contentDescription`
```kotlin
toggle.contentDescription = if (excluded) {
"排除 ${app.label.ifEmpty { pkg }} 的用户数据备份"
} else {
"包含 ${app.label.ifEmpty { pkg }} 的用户数据备份"
}
```
---
### 发现 5/6/7状态文字缺少无障碍实时区域(高)
**文件**
- `fragment_backup.xml` 第 168-169 行statusText
- `fragment_restore.xml` 第 90-91 行statusText
- `fragment_config.xml` 第 384-390 行configStatusText
**问题**:三个状态文字 View 均未设置 `accessibilityLiveRegion`。当代码调用 `updateStatus()``applyState()` 更新文本内容时TalkBack 不会自动朗读变化。
**修复建议**:在布局 XML 中添加:
```xml
android:accessibilityLiveRegion="polite"
```
同时,建议在 `BackupFragment.kt:406``updateStatus` 方法和 `ConfigFragment.kt:136` 设置状态文本后手动触发无障碍事件,确保 TalkBack 播报:
```kotlin
binding.statusText.sendAccessibilityEvent(
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
)
```
---
### 发现 8/9进度指示器状态无通知(中)
**文件**
- `fragment_backup.xml` 第 152-160 行progressBar
- `fragment_restore.xml` 第 73-81 行progressBar
**问题**`LinearProgressIndicator``visibility``VISIBLE`/`GONE` 之间切换(`BackupFragment.kt:402``RestoreFragment.kt:395`),但 TalkBack 不会主动通知用户加载开始或结束。
**修复建议**:在 `setRunning()` 方法中切换进度条可见性时,同步更新 `statusText` 并确保其 `accessibilityLiveRegion` 生效。例如在显示进度条时更新 statusText 为"正在加载…",隐藏时更新为"加载完成"。
---
### 发现 10排序/全选按钮文本过小(中)
**文件**`fragment_backup.xml`
**行号**100-133
**问题**:排序和全选按钮的 `android:textSize` 设置为 `11sp`
```xml
<Button android:textSize="11sp" ... />
```
`11sp` 远低于 Android 推荐的 `14sp` 最小可读文本大小。当用户开启大字模式时,文本可读性受影响。此外按钮使用 `layout_weight="1"` 分布宽度,在窄屏上按钮宽度可能不足 48dp。
**修复建议**
- 将文本大小提升至 `14sp` 以上。
- 添加 `android:minWidth="48dp"` 确保触摸区域不小于 48dp。
- 考虑使用图标+文字的紧凑布局替代纯文字小按钮。
---
### 发现 11/13"用户:" 标签缺少程序化关联(中)
**文件**
- `fragment_backup.xml` 第 39-43 行
- `fragment_restore.xml` 第 49-53 行
**问题**"用户:" 是一个独立的 `TextView`,与后面的 `Spinner` 无程序化关联。TalkBack 用户需自行推断两者关系。
**修复建议**:为 `TextView` 添加 `android:labelFor="@id/userSelector"` 属性:
```xml
<TextView
android:labelFor="@+id/userSelector"
... />
```
---
### 发现 12"输出目录:" 标签缺少程序化关联(低)
**文件**`fragment_backup.xml`
**行号**59-65
**问题**"输出目录:" 标签之后是 `outputPathLabel`(显示路径的 TextView和"修改"按钮。标签与路径显示之间无程序化关联。
**修复建议**:为输出目录标签添加 `labelFor` 指向 `outputPathLabel`,或将其合并为带标题的可操作区域。
---
### 发现 14排除数据切换触摸目标可能不足 48dp(中)
**文件**`PackageListAdapter.kt`
**行号**61-69
**问题**"数据"文本是纯 `TextView`,没有设置 `minWidth``minHeight`。点击区域仅限于文本包裹范围,可能小于 Android 推荐的 48dp 最小触摸目标。
```kotlin
val et = TextView(ctx).apply {
id = R.id.excludeToggle
// 没有 minWidth/minHeight 或最小 padding 保证触摸区域
}
```
**修复建议**:添加最小触摸尺寸:
```kotlin
val et = TextView(ctx).apply {
// ...
minWidth = resources.getDimensionPixelSize(
android.R.dimen.app_icon_size // 48dp
)
minimumHeight = resources.getDimensionPixelSize(
android.R.dimen.app_icon_size
)
}
```
或改用 `MaterialButton` 替代。
---
### 发现 15BottomNavigation 缺少 ContentDescription(低)
**文件**
- `MainActivity.kt` 第 93-100 行
- `res/menu/bottom_nav.xml` 第 2-15 行
**问题**BottomNavigationView 的菜单项包含 `android:title` 文本,但 `android:icon` 引用图标(`@drawable/ic_backup` 等)未设置 `android:contentDescription`。虽然 `android:title` 可被 TalkBack 读取,但图标作为装饰元素应标记为 `android:importantForAccessibility="no"` 以避免冗余播报。
**建议**:在菜单 XML 中为图标装饰属性添加声明(需在 menu 中设置 `app:iconContentDescription` 或在代码中设置)。不过由于 `labelVisibilityMode="labeled"`title 总是可见的TalkBack 可以通过 title 识别,此项优先级较低。
---
## 总结
### 按严重程度统计
| 严重程度 | 数量 |
|----------|------|
| 严重 | 1 |
| 高 | 4 |
| 中 | 8 |
| 低 | 2 |
| **总计** | **15** |
### 按文件分布
| 文件 | 发现问题数 | 最严重问题 |
|------|-----------|-----------|
| PackageListAdapter.kt | 5 | 严重 — TextView 模拟按钮 |
| fragment_backup.xml | 5 | 高 — 缺少无障碍实时区域 |
| fragment_restore.xml | 3 | 高 — 缺少无障碍实时区域 |
| fragment_config.xml | 1 | 高 — 缺少无障碍实时区域 |
| MainActivity.kt | 1 | 低 — BottomNavigation 图标描述 |
### 最优先修复项(共 5 项)
1. **PackageListAdapter.kt:61-69**`TextView` 模拟按钮改为语义化按钮并添加 `contentDescription`
2. **PackageListAdapter.kt:76-88** — 卡片点击区域合并无障碍信息,添加选中/未选中的状态文字
3. **PackageListAdapter.kt:52-53** — CheckBox 绑定应用名作为 `contentDescription`
4. **fragment_backup.xml:168, fragment_restore.xml:90, fragment_config.xml:385** — 为所有状态文字添加 `accessibilityLiveRegion="polite"`
5. **PackageListAdapter.kt:109-115** — 排除切换状态同步到 `contentDescription`
### 项目整体无障碍评估
该应用大量使用 Material Design 3 标准组件,这些组件内置了基本无障碍支持(如 TalkBack 可识别 Button、Switch、CheckBox 等)。主要无障碍缺陷集中在**程序化创建的列表项**`PackageListAdapter`)和**动态状态更新**缺乏通知机制。修复优先级建议从 PackageListAdapter 的 5 个问题开始,它们影响最核心的交互流程(应用选择和排除数据)。配置页面的无障碍支持较好(使用 TextInputLayout + hint 提供标签)。总体评分 **6/10**,完成上述修复后可提升至 **8/10**

View File

@@ -8,8 +8,6 @@ kover {
filters {
excludes {
classes(
// Generated/auto classes
"*.databinding.*",
"*.BuildConfig",
"*.R",
"*.R\$*"
@@ -26,32 +24,36 @@ android {
applicationId "com.example.androidbackupgui"
minSdk 24
targetSdk 34
versionCode 3
versionName "1.2"
versionCode 16
versionName "1.16"
}
buildFeatures {
viewBinding true
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
lint {
disable 'QueryAllPackagesPermission'
}
signingConfigs {
release {
def keystoreFile = file("release.keystore")
if (keystoreFile.exists()) {
storeFile keystoreFile
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "android"
keyAlias "release"
keyPassword System.getenv("KEY_PASSWORD") ?: "android"
}
storeFile rootProject.file("app/release.keystore")
storePassword System.getenv("KEYSTORE_PASSWORD")
keyAlias "release"
keyPassword System.getenv("KEY_PASSWORD")
v1SigningEnabled true
v2SigningEnabled true
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
if (file("release.keystore").exists()) {
signingConfig signingConfigs.release
if (rootProject.file("app/release.keystore").exists()) {
def ksPass = System.getenv("KEYSTORE_PASSWORD")
def kPass = System.getenv("KEY_PASSWORD")
if (ksPass != null && kPass != null) {
signingConfig signingConfigs.release
}
}
}
}
@@ -72,29 +74,53 @@ android {
jniLibs {
useLegacyPackaging true
}
resources {
excludes += [
'org/bouncycastle/pqc/crypto/picnic/lowmcL5.bin.properties',
'org/bouncycastle/pqc/crypto/picnic/lowmcL3.bin.properties',
'org/bouncycastle/pqc/crypto/picnic/lowmcL1.bin.properties',
]
}
}
}
dependencies {
// Compose BOM (manages all Compose library versions)
implementation platform('androidx.compose:compose-bom:2024.02.00')
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.foundation:foundation'
implementation 'androidx.compose.material:material-icons-extended'
implementation 'androidx.activity:activity-compose:1.8.2'
debugImplementation 'androidx.compose.ui:ui-tooling'
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.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.fragment:fragment-ktx:1.6.2'
implementation 'androidx.viewpager2:viewpager2:1.0.0'
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.7.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
// 方案A: jcifs-ng (SMB) + sardine-android (WebDAV) 替代 rclone serve
implementation "eu.agno3.jcifs:jcifs-ng:2.1.10"
implementation "com.github.thegrizzlylabs:sardine-android:v0.9"
implementation("eu.agno3.jcifs:jcifs-ng:2.1.10") {
exclude group: 'org.bouncycastle'
}
implementation("com.github.thegrizzlylabs:sardine-android:v0.9") {
exclude group: 'xpp3'
exclude group: 'stax'
}
implementation "org.slf4j:slf4j-android:1.7.36"
// root shell via libsu (Magisk/KernelSU/APatch)
implementation 'com.github.topjohnwu:libsu:6.0.0'
// Full BouncyCastle provider (includes MD4 required by jcifs-ng SMB)
implementation 'org.bouncycastle:bcprov-jdk15to18:1.77'
implementation 'org.nanohttpd:nanohttpd:2.3.1'
testImplementation "io.kotest:kotest-runner-junit5:5.9.1"
testImplementation "io.kotest:kotest-assertions-core:5.9.1"
testImplementation "io.kotest:kotest-property:5.9.1"

1742
app/lint-baseline.xml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1,58 @@
# Add project specific ProGuard rules here.
# ProGuard/R8 rules for Android Backup GUI
# ==========================================
# --- kotlinx.serialization ---
# Keep @SerialName classes and companion serializer fields
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** {
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class com.example.androidbackupgui.**$$serializer { *; }
-keepclassmembers class com.example.androidbackupgui.** {
*** Companion;
}
-keepclasseswithmembers class com.example.androidbackupgui.** {
kotlinx.serialization.KSerializer serializer(...);
}
# --- NanoHTTPD ---
# NanoHTTPD (package fi.iki.elonen despite Maven group org.nanohttpd)
-keep class fi.iki.elonen.** { *; }
# --- RemoteTransport (WebDAV/SMB) ---
-keep class com.example.androidbackupgui.backup.RemoteTransport { *; }
# --- Data classes (serialization) ---
-keep class com.example.androidbackupgui.backup.ResticProgress { *; }
-keep class com.example.androidbackupgui.backup.BackupSummary { *; }
-keep class com.example.androidbackupgui.backup.ResticSnapshot { *; }
-keep class com.example.androidbackupgui.backup.RestoreProgress { *; }
-keep class com.example.androidbackupgui.backup.BackupConfig { *; }
-keep class com.example.androidbackupgui.backup.AppError { *; }
-keep class com.example.androidbackupgui.backup.AppResult { *; }
# --- RemoteTransport implementations ---
-keep class com.example.androidbackupgui.backup.SmbTransport { *; }
-keep class com.example.androidbackupgui.backup.WebdavTransport { *; }
# --- WifiManager (called from UI, kept for safety) ---
-keep class com.example.androidbackupgui.backup.WifiManager { *; }
# --- Keep data models used by kotlinx.serialization ---
## Keep all model classes that may be referenced via @Serializable
-keep class com.example.androidbackupgui.model.** { *; }
# --- Keep R classes (referenced by code) ---
-keep class com.example.androidbackupgui.R { *; }
# --- jcifs-ng (SMB) keep class/member names for reflection (was MD4Provider) ---
-keep class jcifs.util.Crypto { *; }
-keep class jcifs.smb.NtlmUtil { *; }
-keep class jcifs.ntlmssp.Type3Message { *; }
-keep class jcifs.smb.NtlmContext { *; }

Binary file not shown.

View File

@@ -10,7 +10,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:allowBackup="false"
android:extractNativeLibs="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"

View File

@@ -1,118 +1,49 @@
package com.example.androidbackupgui
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.example.androidbackupgui.databinding.ActivityMainBinding
import com.example.androidbackupgui.root.RootShell
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
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.ui.BackupFragment
import com.example.androidbackupgui.ui.ConfigFragment
import com.example.androidbackupgui.ui.RestoreFragment
import com.example.androidbackupgui.backup.MissingAlgoProvider
import com.example.androidbackupgui.backup.PasswordManager
import com.example.androidbackupgui.backup.ResticBinary
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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val pageTitles = listOf("应用备份", "应用恢复", "备份配置")
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Apply Dynamic Colors (Material You) if available
DynamicColors.applyToActivitiesIfAvailable(application)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// Configure libsu with global mount namespace support
RootShell.configure()
// Request root access on startup
lifecycleScope.launch {
withContext(Dispatchers.IO) {
RootShell.ensureSession()
// Initialize restic binary path
ResticBinary.prepare(this)?.let { defaultResticWrapper.binaryPath = it }
// 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,
) {
AppScaffold()
}
}
// Initialize file-based logging
LogUtil.init(filesDir)
}
// Edge-to-edge: distribute system bar insets (status bar, nav bar, cutout) to children
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets ->
val statusBars = insets.getInsets(WindowInsetsCompat.Type.statusBars())
val navBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
// Pad toolbar below status bar (preserve existing horizontal padding)
binding.topAppBar.setPadding(
binding.topAppBar.paddingLeft,
statusBars.top,
binding.topAppBar.paddingRight,
binding.topAppBar.paddingBottom
)
// Pad bottom nav above navigation bar so menu items are visible
binding.bottomNav.setPadding(
binding.bottomNav.paddingLeft,
binding.bottomNav.paddingTop,
binding.bottomNav.paddingRight,
navBars.bottom
)
// Pad view pager above navigation bar so fragment content doesn't overlap nav bar
binding.viewPager.setPadding(
binding.viewPager.paddingLeft,
binding.viewPager.paddingTop,
binding.viewPager.paddingRight,
navBars.bottom
)
insets
}
val fragments = listOf(
BackupFragment(),
RestoreFragment(),
ConfigFragment()
)
binding.viewPager.adapter = TabAdapter(this, fragments)
binding.viewPager.isUserInputEnabled = true
binding.viewPager.offscreenPageLimit = 2
binding.bottomNav.setOnItemSelectedListener { item ->
when (item.itemId) {
R.id.nav_backup -> binding.viewPager.currentItem = 0
R.id.nav_restore -> binding.viewPager.currentItem = 1
R.id.nav_config -> binding.viewPager.currentItem = 2
}
true
}
// Sync ViewPager -> BottomNav + Toolbar title
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
binding.bottomNav.menu.getItem(position).isChecked = true
binding.topAppBar.title = pageTitles[position]
}
})
}
private class TabAdapter(
activity: FragmentActivity,
private val fragments: List<Fragment>
) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = fragments.size
override fun createFragment(position: Int): Fragment = fragments[position]
}
}

View File

@@ -7,15 +7,6 @@ import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
@Serializable
data class DataSizes(
val apkBytes: Long = 0,
val userBytes: Long = 0,
val userDeBytes: Long = 0,
val dataBytes: Long = 0,
val obbBytes: Long = 0,
val mediaBytes: Long = 0,
)
@Serializable
data class AppInfo(
@@ -30,7 +21,6 @@ data class AppInfo(
val userId: UserId = UserId(0),
val hasKeystore: Boolean = false,
val iconPath: String? = null,
val dataSizes: DataSizes = DataSizes(),
)
object AppScanner {
@@ -101,16 +91,6 @@ object AppScanner {
.filter { it.isNotEmpty() }
}
/** Get the app label/name. */
suspend fun getAppLabel(packageName: String): String = withContext(Dispatchers.IO) {
val result = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep -A1 'ApplicationInfo' | grep 'label=' | head -1")
val label = result.output
.substringAfter("label=", "")
.substringBefore(" ")
.removeSurrounding("\"")
.trim()
label.ifEmpty { packageName }
}
/** Check if a package has OBB data. */
suspend fun hasObbData(packageName: String): Boolean = withContext(Dispatchers.IO) {

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,72 +12,113 @@ 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)
// 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 {
/**
* Unescape a quoted config value. Reverses [escapeValue]: turns \\ and \"
* back into \ and ". Applied only to values that were stored inside quotes.
*/
private fun unescapeValue(s: String): String {
if (s.indexOf('\\') < 0) return s
val sb = StringBuilder(s.length)
var i = 0
while (i < s.length) {
val c = s[i]
if (c == '\\' && i + 1 < s.length) {
sb.append(s[i + 1])
i += 2
} else {
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("\"", "\\\"")
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 props = mutableMapOf<String, String>()
file.forEachLine { line ->
val trimmed = line.trim()
@@ -85,15 +126,35 @@ data class BackupConfig(
val eq = trimmed.indexOf('=')
if (eq < 0) return@forEachLine
val key = trimmed.substring(0, eq).trim()
val value = trimmed.substring(eq + 1).trim().removeSurrounding("\"")
props[key] = value
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))
} else {
// Legacy/unquoted value — fall back to trimmed form.
unescapeValue(v.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", " ") }
}
@@ -114,6 +175,7 @@ data class BackupConfig(
backupObbData = int("Backup_obb_data", default = 1),
backupMedia = int("backup_media"),
backgroundAppsIgnore = int("Background_apps_ignore"),
backupUserId = int("backup_user_id"),
customPath = lines("Custom_path"),
blacklistMode = int("blacklist_mode"),
blacklist = lines("blacklist"),
@@ -126,63 +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=\"${config.outputPath}\"")
appendLine("list_location=\"${config.listLocation}\"")
appendLine("update=${config.update}")
appendLine("cdn=${config.cdn}")
appendLine("mount_point=\"${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("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=\"${config.resticRepo}\"")
appendLine("restic_password=\"${config.resticPassword}\"")
appendLine("restic_backend=${config.resticBackend}")
appendLine("restic_backend_url=\"${config.resticBackendUrl}\"")
appendLine("restic_backend_user=\"${config.resticBackendUser}\"")
appendLine("restic_backend_pass=\"${config.resticBackendPass}\"")
appendLine("restic_backend_share=\"${config.resticBackendShare}\"")
appendLine("restic_backend_domain=\"${config.resticBackendDomain}\"")
})
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,19 +1,22 @@
package com.example.androidbackupgui.backup
import com.example.androidbackupgui.root.RootShell
import android.util.Log
import com.example.androidbackupgui.backup.ResticWrapper.SnapshotAppInfo
import com.example.androidbackupgui.root.RootShell
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.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
/**
@@ -21,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
@@ -29,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
@@ -39,7 +41,7 @@ object BackupOperation {
val failCount: Int,
val skippedCount: Int,
val outputDir: String,
val elapsedMs: Long
val elapsedMs: Long,
)
/**
@@ -48,7 +50,11 @@ object BackupOperation {
* @param config backup configuration
* @param outputDir root output directory
* @param userId Android user ID (0, 999, etc.)
* @param onProgress callback for UI updates
* @param includePkgs if non-empty, only backup apps whose package name is in this set;
* metadata (app_details.json, appList.txt) is still generated for all [apps].
* @param legacyApps metadata from a previous snapshot used to populate app_details.json
* for apps not in [apps] (keeps them in the cumulative snapshot record
* without requiring re-scans of possibly-uninstalled apps).
*/
suspend fun backupApps(
context: android.content.Context,
@@ -56,129 +62,287 @@ object BackupOperation {
config: BackupConfig,
outputDir: File,
userId: String = "0",
onProgress: suspend (BackupProgress) -> Unit = {}
): BackupResult = withContext(Dispatchers.IO) {
val emit: suspend (BackupProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
val startTime = System.currentTimeMillis()
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()
// Create backup structure
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
backupRoot.mkdirs()
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
val appListFile = File(backupRoot, "appList.txt")
appListFile.writeText(apps.joinToString("\n") { it.packageName.value })
// 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
val metaFile = File(backupRoot, "app_details.json")
metaFile.writeText(buildAppDetailsJson(apps))
val semaphore = Semaphore(3)
val successAtomic = AtomicInteger(0)
val failAtomic = AtomicInteger(0)
val skippedAtomic = AtomicInteger(0)
coroutineScope {
apps.mapIndexed { index, app ->
async {
semaphore.withPermit {
ensureActive()
val appDir = File(backupRoot, app.packageName.value)
appDir.mkdirs()
emit(BackupProgress(index + 1, apps.size, 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, apps.size, 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, apps.size, app.packageName.value, "data", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失"))
}
// 2. Backup user data (if configured)
if (config.backupMode == 1 && config.backupUserData == 1) {
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "data", "正在备份数据…"))
if (!backupUserData(context, app.packageName.value, appDir, userId, config.compressionMethod)) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, 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, apps.size, app.packageName.value, "obb", "正在备份 OBB…"))
if (!backupObb(app.packageName.value, appDir, config.compressionMethod)) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "done", "OBB 备份失败"))
return@withPermit
}
}
}
// 4. Backup SSAID
emit(BackupProgress(index + 1, apps.size, 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, apps.size, 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"
@@ -199,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
@@ -213,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)}'")
}
@@ -226,125 +395,421 @@ 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 true
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) {
File(appDir, "ssaid.txt").writeText(value)
Log.d(TAG, "backupSsaid: backed up SSAID for $packageName = $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")
}
}
}
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()) {
File(appDir, "permissions.txt").writeText(result.output)
val permFile = File(appDir, "permissions.txt")
if (!writeFileForBackup(permFile, result.output)) {
Log.w(TAG, "backupPermissions: failed to write permissions.txt for $packageName")
}
}
}
private fun buildAppDetailsJson(apps: List<AppInfo>): String {
internal suspend fun buildAppDetailsJson(
apps: List<AppInfo>,
legacyApps: Map<String, SnapshotAppInfo>? = null,
perAppExtra: Map<String, PerAppExtra>? = null,
): String {
val root = JSONObject()
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)
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
}
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)
}
// Legacy apps from previous snapshot
val legacyMap = legacyApps ?: emptyMap()
for ((pkg, legacy) in legacyMap) {
if (!root.has(pkg)) {
val entry = JSONObject()
entry.put("label", legacy.label)
entry.put("isSystem", legacy.isSystem)
entry.put("apkSizes", JSONArray(legacy.apkSizes))
root.put(pkg, entry)
}
}
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,160 @@
package com.example.androidbackupgui.backup
import android.util.Log
import org.bouncycastle.crypto.digests.MD4Digest
import org.bouncycastle.crypto.engines.AESEngine
import org.bouncycastle.crypto.macs.CMac
import org.bouncycastle.crypto.params.KeyParameter
import java.security.MessageDigest
import java.security.MessageDigestSpi
import java.security.Provider
import java.security.Security
import java.security.spec.AlgorithmParameterSpec
import javax.crypto.MacSpi
/**
* Injects missing algorithms (MD4, AESCMAC) into Android's BC provider
* for jcifs-ng SMB support.
*
* jcifs-ng instantiates [BouncyCastleProvider] and requests algorithms
* ([MessageDigest]"MD4", [Mac]"AESCMAC") that Android's built-in BC
* has removed. The BouncyCastleProvider class is shadowed by the boot
* classloader, so we patch `jcifs.util.Crypto.provider` via reflection.
*/
object MissingAlgoProvider {
private const val TAG = "MissingAlgoProvider"
private val registered = java.util.concurrent.atomic.AtomicBoolean(false)
private val patchProvider: Provider by lazy {
val bc = Security.getProvider("BC")
DelegatingBcProvider(bc)
}
fun register() {
if (!registered.compareAndSet(false, true)) return
try {
// 1. Replace cached provider in jcifs-ng classes
for (cn in listOf(
"jcifs.util.Crypto",
"jcifs.smb.NtlmUtil",
"jcifs.smb.NtlmPasswordAuthenticator",
"jcifs.ntlmssp.Type3Message",
"jcifs.smb.NtlmContext"
)) setProviderField(cn)
// 2. Verify
try {
val cl = Class.forName("jcifs.util.Crypto")
val getProv = cl.getDeclaredMethod("getProvider")
getProv.isAccessible = true
val actual = getProv.invoke(null) as Provider
Log.i(TAG, "Crypto.getProvider() => ${actual::class.java.simpleName} " +
"(hasMD4=${actual.getService("MessageDigest", "MD4") != null}, " +
"hasAESCMAC=${actual.getService("Mac", "AESCMAC") != null})")
} catch (ve: Exception) {
Log.w(TAG, "Verification failed after injection", ve)
}
// 3. Fallback: register a global provider that wraps BC + MD4 + AESCMAC
try {
Security.insertProviderAt(GlobalPatchProvider(), 1)
Log.i(TAG, "Registered GlobalPatchProvider at position 1")
} catch (e: Exception) {
Log.w(TAG, "Failed to register global patch provider", e)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to inject algorithms", e)
}
}
private fun setProviderField(clsName: String) {
try {
val cls = Class.forName(clsName)
for (f in cls.declaredFields) {
if (java.lang.reflect.Modifier.isStatic(f.modifiers) &&
Provider::class.java.isAssignableFrom(f.type)) {
f.isAccessible = true
f.set(null, patchProvider)
Log.i(TAG, "Set $clsName.${f.name} = DelegatingBcProvider")
return
}
}
Log.i(TAG, "No static Provider field in $clsName")
} catch (_: ClassNotFoundException) {
Log.i(TAG, "Class not found: $clsName")
}
}
// ── MD4 MessageDigestSpi ────────────────────────────────────
class Md4Spi : MessageDigestSpi() {
private val d = MD4Digest()
override fun engineGetDigestLength() = d.digestSize
override fun engineUpdate(b: Byte) { d.update(b) }
override fun engineUpdate(b: ByteArray, o: Int, l: Int) { d.update(b, o, l) }
override fun engineDigest(): ByteArray {
val r = ByteArray(d.digestSize); d.doFinal(r, 0); return r
}
override fun engineReset() { d.reset() }
}
// ── AESCMAC MacSpi ─────────────────────────────────────────
class AesCmacSpi : MacSpi() {
private val mac = CMac(AESEngine.newInstance())
override fun engineInit(key: java.security.Key, params: AlgorithmParameterSpec?) {
val raw = key.encoded ?: throw java.security.InvalidKeyException("AESCMAC key has no encoded form")
mac.init(KeyParameter(raw))
}
override fun engineUpdate(inp: Byte) { mac.update(inp) }
override fun engineUpdate(inp: ByteArray, o: Int, l: Int) { mac.update(inp, o, l) }
override fun engineDoFinal(): ByteArray {
val r = ByteArray(mac.macSize); mac.doFinal(r, 0); return r
}
override fun engineGetMacLength() = mac.macSize
override fun engineReset() { mac.reset() }
}
// ── Delegating provider ─────────────────────────────────────
/** A "BC"-named provider that delegates to [bc] except for patched algorithms. */
private class DelegatingBcProvider(
private val bc: Provider?
) : Provider("BC", bc?.version ?: 1.0, "BC + patches") {
init {
putService(Service(this, "MessageDigest", "MD4",
Md4Spi::class.java.name, null, null))
putService(Service(this, "Mac", "AESCMAC",
AesCmacSpi::class.java.name, null, null))
}
override fun getService(type: String, algorithm: String): Service? {
if (type == "MessageDigest" && algorithm.equals("MD4", ignoreCase = true)) return super.getService(type, algorithm)
if (type == "Mac" && algorithm.equals("AESCMAC", ignoreCase = true)) return super.getService(type, algorithm)
return bc?.getService(type, algorithm)
}
override fun getServices(): MutableSet<Service> {
val s = (bc?.getServices() ?: emptySet<Service>()).toMutableSet()
s.addAll(super.getServices())
return s
}
}
}
/**
* Standalone provider registered globally as fallback so that
* [java.security.Security.getProvider]("BC") or any lazy-loaded
* BouncyCastleProvider instance can find MD4 and AESCMAC.
* Named differently ("MissingAlgoProvider") to avoid conflict with "BC".
*/
private class GlobalPatchProvider : Provider(
"MissingAlgoProvider", 1.0, "MD4 + AESCMAC fallback"
) {
init {
put("MessageDigest.MD4", MissingAlgoProvider.Md4Spi::class.java.name)
put("Mac.AESCMAC", MissingAlgoProvider.AesCmacSpi::class.java.name)
}
}

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,231 +0,0 @@
package com.example.androidbackupgui.backup
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import java.io.File
/**
* Manages remote transport lifecycle (SMB/WebDAV) and local temp repo sync.
*
* For SMB/WebDAV backends, restic runs against a local temp directory;
* [RemoteTransport] syncs files to/from the remote backend.
*
* All sync operations are serialized via [repoSyncMutex] so concurrent
* operations don't corrupt the local temp repo.
*/
class RemoteSyncManager {
private sealed interface SyncEvent {
data class Phase(val progress: RemoteTransport.TransferProgress) : SyncEvent
data class Bytes(val progress: RemoteTransport.ByteProgress) : SyncEvent
}
private val TAG = "ResticWrapper"
/** Local temp directory used as restic repo for SMB/WebDAV backends. */
@Volatile
var tempRepoDir: String = ""
/** Domain for SMB NTLM authentication. */
@Volatile
var backendDomain: String = ""
// ── Transport cache ──────────────────────────────────
@Volatile private var transport: RemoteTransport? = null
private var transportConfigKey: String = ""
private val transportLock = Any()
/** Serializes access to tempRepoDir so concurrent operations don't corrupt each other. */
private val repoSyncMutex = Mutex()
// ── Transport lifecycle ──────────────────────────────
private fun ensureTransport(
backend: String, url: String, user: String, pass: String, share: String, repoPath: String
): RemoteTransport? = synchronized(transportLock) {
val key = "$backend|$url|$user|${pass.hashCode()}|$share|$backendDomain|$repoPath"
if (key != transportConfigKey || transport == null) {
transport?.let { Log.i(TAG, "transport config changed, recreating") }
// Clear local temp repo when backend config changes so
// syncFromRemote downloads fresh data from the new backend
if (transportConfigKey.isNotEmpty() && tempRepoDir.isNotEmpty()) {
val dir = File(tempRepoDir)
val deleted = dir.deleteRecursively()
Log.i(TAG, "cleared local temp repo: $tempRepoDir (deleted=$deleted)")
dir.mkdirs()
}
transport = RemoteTransport.create(backend, url, user, pass, share, backendDomain)
if (transport != null) {
transportConfigKey = key
Log.i(TAG, "transport created: $backend @ $url repo=$repoPath domain=$backendDomain")
} else {
Log.e(TAG, "transport creation failed for backend=$backend url=$url")
}
}
return transport
}
// ── Temp dir lifecycle ───────────────────────────────
/** Clean up local temp repo and cache directories. */
private fun cleanupTempDirs() {
if (tempRepoDir.isEmpty()) return
try {
val repoDir = File(tempRepoDir)
if (repoDir.exists()) {
val deleted = repoDir.deleteRecursively()
Log.i(TAG, "cleanupTempDirs: deleted $tempRepoDir ($deleted)")
}
val cacheDir = File(tempRepoDir.substringBeforeLast("/") + "/restic_cache")
if (cacheDir.exists()) {
val deleted = cacheDir.deleteRecursively()
Log.i(TAG, "cleanupTempDirs: deleted cache $cacheDir ($deleted)")
}
val tmpDir = File(tempRepoDir.substringBeforeLast("/") + "/restic_tmp")
if (tmpDir.exists()) {
val deleted = tmpDir.deleteRecursively()
Log.i(TAG, "cleanupTempDirs: deleted tmp $tmpDir ($deleted)")
}
} catch (e: Exception) {
Log.w(TAG, "cleanupTempDirs failed: ${e.message}")
}
}
/** True if [tempRepoDir] already contains an initialized restic repository (has a config file). */
private fun isLocalRepoPopulated(): Boolean {
if (tempRepoDir.isEmpty()) return false
return File(tempRepoDir, "config").isFile
}
// ── Sync engine ──────────────────────────────────────
/**
* Execute [action] with remote repo synced before/after as needed.
* For local/rest-server backends, executes [action] directly without sync.
* Protected by [repoSyncMutex] so concurrent operations don't corrupt tempRepoDir.
*
* Cleanup strategy:
* - Write ops (needsUpload=true): cleanup only on successful sync to remote.
* On syncToRemote failure the local repo is preserved so the next
* operation can retry — destroying it would lose the just-created snapshot.
* - Read-only ops (needsUpload=false): keep local cache for subsequent operations.
* - Read-only ops skip download entirely if local repo is already populated.
*/
suspend fun <T> withRemoteSync(
backend: String,
backendUrl: String,
backendUser: String,
backendPass: String,
backendShare: String,
repoPath: String,
needsDownload: Boolean,
needsUpload: Boolean,
onProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
action: suspend () -> AppResult<T>
): AppResult<T> {
if (backend != "smb" && backend != "webdav") return action()
return repoSyncMutex.withLock {
coroutineScope {
var shouldCleanup = false
var lastByteEmitMs = 0L
val progressChannel = Channel<SyncEvent>(CONFLATED)
val progressJob = launch(Dispatchers.Main) {
for (event in progressChannel) {
when (event) {
is SyncEvent.Phase -> onProgress(event.progress)
is SyncEvent.Bytes -> {
val now = System.currentTimeMillis()
if (now - lastByteEmitMs >= 50) {
onByteProgress(event.progress)
lastByteEmitMs = now
}
}
}
}
}
try {
val t = ensureTransport(backend, backendUrl, backendUser, backendPass, backendShare, repoPath)
?: return@coroutineScope err(AppError.Remote("Failed to create transport for backend: $backend", "connecting"))
val localDir = File(tempRepoDir)
val emitProgress: suspend (RemoteTransport.TransferProgress) -> Unit = { p ->
progressChannel.send(SyncEvent.Phase(p))
}
val emitByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit = { p ->
progressChannel.send(SyncEvent.Bytes(p))
}
// Write ops always download to avoid overwriting remote changes.
// Read-only ops skip download if local repo is already present.
val actualDownload = needsDownload && (needsUpload || !isLocalRepoPopulated())
if (actualDownload) {
Log.i(TAG, "syncFromRemote start: $repoPath -> $tempRepoDir")
val syncResult = RemoteTransport.syncFromRemote(t, localDir, repoPath, emitProgress, emitByteProgress)
if (syncResult.isFailure) {
shouldCleanup = true
Log.e(TAG, "syncFromRemote FAILED: ${syncResult.exceptionOrNull()?.message}")
return@coroutineScope err(AppError.Remote("syncFromRemote failed: ${syncResult.exceptionOrNull()?.message}", "download"))
}
Log.i(TAG, "syncFromRemote complete")
} else if (needsDownload) {
Log.i(TAG, "syncFromRemote skipped: local repo already populated")
}
val result = action()
if (needsUpload && result.isSuccess) {
Log.i(TAG, "syncToRemote start: $tempRepoDir -> $repoPath")
val uploadResult = RemoteTransport.syncToRemote(t, localDir, repoPath, emitProgress, emitByteProgress)
if (uploadResult.isFailure) {
shouldCleanup = false // PRESERVE local repo — snapshot would be lost
Log.e(TAG, "syncToRemote FAILED: ${uploadResult.exceptionOrNull()?.message} — local repo preserved for retry")
return@coroutineScope err(AppError.Remote("syncToRemote failed: ${uploadResult.exceptionOrNull()?.message}", "upload"))
}
Log.i(TAG, "syncToRemote complete")
shouldCleanup = true
} else if (result.isFailure) {
shouldCleanup = true
}
result
} catch (e: CancellationException) {
shouldCleanup = true
throw e
} catch (e: Exception) {
shouldCleanup = true
err(AppError.Remote(e.message ?: "Unknown error", "sync", cause = e))
} finally {
progressChannel.close()
progressJob.join()
if (shouldCleanup) {
Log.i(TAG, "withRemoteSync: cleaning up temp dirs")
cleanupTempDirs()
} else {
Log.d(TAG, "withRemoteSync: keeping local repo for subsequent ops")
}
}
}
}
}
/**
* Public safety-net cleanup called by fragment lifecycle.
* Waits for any in-progress operation to finish, then deletes temp dirs.
*/
suspend fun cleanup() {
repoSyncMutex.withLock { cleanupTempDirs() }
}
}

View File

@@ -1,11 +1,5 @@
package com.example.androidbackupgui.backup
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import java.io.File
import kotlinx.serialization.Serializable
@@ -48,56 +42,10 @@ interface RemoteTransport {
suspend fun delete(remotePath: String): AppResult<Unit>
suspend fun exists(remotePath: String): AppResult<Boolean>
/** Get the size of a remote file in bytes. Returns [AppResult.Failure] if not found. */
suspend fun fileSize(remotePath: String): AppResult<Long>
companion object {
private const val TAG = "RemoteTransport"
private const val MAX_RETRIES = 3
/**
* Returns true if the exception indicates a transient error worth retrying
* (network blip, DNS hiccup, server 5xx), false for permanent errors (4xx).
*/
private fun isTransientError(e: Exception): Boolean {
val msg = (e.message ?: "") + (e.cause?.message ?: "")
// DNS / network-layer failures
if (msg.contains("Unable to resolve host", ignoreCase = true)) return true
if (msg.contains("No address associated", ignoreCase = true)) return true
if (msg.contains("ConnectException", ignoreCase = true)) return true
if (msg.contains("SocketTimeoutException", ignoreCase = true)) return true
if (msg.contains("timeout", ignoreCase = true)) return true
if (msg.contains("Connection refused", ignoreCase = true)) return true
if (msg.contains("Network is unreachable", ignoreCase = true)) return true
// 5xx server errors (502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout)
if (Regex("\\b5\\d{2}\\b").containsMatchIn(msg)) return true
return false
}
/**
* Execute [block] with retries on transient failures.
* Uses exponential backoff: 1s, 2s, 4s.
*/
private suspend fun <T> withRetry(
tag: String,
block: suspend () -> AppResult<T>
): AppResult<T> {
var lastError: AppResult<T>? = null
for (attempt in 0..MAX_RETRIES) {
if (attempt > 0) {
val waitMs = 1000L * (1 shl (attempt - 1)) // 1s, 2s, 4s
Log.w(TAG, "$tag retry $attempt/$MAX_RETRIES after ${waitMs}ms")
delay(waitMs)
}
val result = block()
if (result.isSuccess) return result
val err = result.exceptionOrNull()
if (err != null && err is Exception && isTransientError(err)) {
lastError = result
continue
}
return result // permanent error — don't retry
}
return lastError ?: err(AppError.Remote("$tag: max retries exceeded", "retry"))
}
fun create(
backend: String,
@@ -119,252 +67,6 @@ interface RemoteTransport {
else -> null
}
}
/**
* Download all files from remote [remoteDir] into [localDir] recursively,
* skipping files that already exist locally with the same size.
* Deletes local files no longer present on the remote.
* Returns failure if any download fails.
*/
suspend fun syncFromRemote(
transport: RemoteTransport,
localDir: File,
remoteDir: String,
onProgress: suspend (TransferProgress) -> Unit = {},
onByteProgress: suspend (ByteProgress) -> Unit = {}
): AppResult<Unit> = withContext(Dispatchers.IO) {
try {
localDir.mkdirs()
val remoteFiles = listRemoteRecursive(transport, remoteDir)
// Root dir not found (404): treat as empty remote — nothing to download.
// This is normal for first-time init where the repo doesn't exist yet.
if (remoteFiles == null) {
Log.w(TAG, "syncFromRemote: remote dir '$remoteDir' not accessible, treating as empty")
return@withContext AppResult.Success(Unit)
}
onProgress(TransferProgress("list", 0, remoteFiles.size))
val remoteByPath = remoteFiles.associateBy { it.path }
val errors = mutableListOf<String>()
// Download remote files that are new or have different size
var transferred = 0
var skipped = 0
val syncTotal = remoteFiles.size
for ((relPath, info) in remoteByPath) {
val localFile = File(localDir, relPath)
if (localFile.isFile && localFile.length() == info.size) {
Log.d(TAG, "syncFromRemote skip (same size): $relPath")
skipped++
continue
}
transferred++
onProgress(TransferProgress("download", transferred, syncTotal, relPath))
localFile.parentFile?.mkdirs()
val fullRemotePath = "$remoteDir/$relPath"
Log.i(TAG, "syncFromRemote downloading: $fullRemotePath (${info.size} bytes)")
val result = withRetry("download($fullRemotePath)") {
transport.download(fullRemotePath, localFile.absolutePath, onProgress, onByteProgress)
}
if (result.isFailure) {
errors.add("$fullRemotePath: ${result.exceptionOrNull()?.message}")
}
}
// If any download failed, abort before deleting local files —
// deleting would destroy valid data for an incomplete sync.
if (errors.isNotEmpty()) {
return@withContext err(AppError.Remote("syncFromRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}", "sync"))
}
// Delete local files not on remote (e.g. after prune on another client)
val localFiles = walkLocalFiles(localDir)
val staleLocalPaths = localFiles.keys.filter { it !in remoteByPath }
val staleCount = staleLocalPaths.size
for ((staleIdx, relPath) in staleLocalPaths.withIndex()) {
onProgress(TransferProgress("delete_stale", staleIdx + 1, staleCount))
val localFile = localFiles[relPath] ?: continue
Log.i(TAG, "syncFromRemote deleting stale local: $relPath")
try { localFile.delete() } catch (_: Exception) {}
}
onProgress(TransferProgress("complete", transferred, syncTotal, "已传输: $transferred 跳过: $skipped"))
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
err(AppError.Remote("syncFromRemote failed: ${e.message}", "sync", cause = e))
}
}
/**
* Upload all files from [localDir] into [remoteDir] recursively,
* skipping files that already exist remotely with the same size.
* Deletes remote files that no longer exist locally.
* Returns failure if any upload fails.
*/
suspend fun syncToRemote(
transport: RemoteTransport,
localDir: File,
remoteDir: String,
onProgress: suspend (TransferProgress) -> Unit = {},
onByteProgress: suspend (ByteProgress) -> Unit = {}
): AppResult<Unit> = withContext(Dispatchers.IO) {
try {
val localFiles = walkLocalFiles(localDir)
onProgress(TransferProgress("list", 0, localFiles.size))
val remoteResult = listRemoteRecursive(transport, remoteDir)
// If the remote dir is not accessible (404 or network error), treat as empty.
// Any real upload errors will surface during the actual file uploads below.
if (remoteResult == null) {
Log.w(TAG, "syncToRemote: remote dir '$remoteDir' not accessible, treating as empty")
}
val remoteByPath = (remoteResult ?: emptyList()).associateBy { it.path }
val errors = mutableListOf<String>()
// Collect unique parent directories that need to exist on remote
val remoteDirs = mutableSetOf<String>()
for (relPath in localFiles.keys) {
val parent = relPath.substringBeforeLast("/", "")
if (parent.isNotEmpty()) remoteDirs.add(parent)
}
// Ensure all remote directories exist
for (dir in remoteDirs) {
transport.mkdirs("$remoteDir/$dir")
}
// Upload new or changed local files
var uploaded = 0
var uploadSkipped = 0
val syncTotal = localFiles.size
for ((relPath, localFile) in localFiles) {
val remoteInfo = remoteByPath[relPath]
if (remoteInfo != null && remoteInfo.size == localFile.length()) {
Log.d(TAG, "syncToRemote skip (same size): $relPath")
uploadSkipped++
continue
}
uploaded++
onProgress(TransferProgress("upload", uploaded, syncTotal, relPath))
val fullRemotePath = "$remoteDir/$relPath"
Log.i(TAG, "syncToRemote uploading: $fullRemotePath (${localFile.length()} bytes)")
val result = withRetry("upload($fullRemotePath)") {
transport.upload(localFile.absolutePath, fullRemotePath, onProgress, onByteProgress)
}
if (result.isFailure) {
errors.add("$fullRemotePath: ${result.exceptionOrNull()?.message}")
}
}
// If any upload failed, abort before deleting remote files —
// deleting during failed sync could lose the only copy on remote.
if (errors.isNotEmpty()) {
return@withContext err(AppError.Remote("syncToRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}", "sync"))
}
// Delete remote files no longer present locally
val staleRemotePaths = remoteByPath.keys.filter { it !in localFiles }
val staleCount = staleRemotePaths.size
for ((staleIdx, relPath) in staleRemotePaths.withIndex()) {
onProgress(TransferProgress("delete_stale", staleIdx + 1, staleCount))
Log.i(TAG, "syncToRemote deleting stale: $relPath")
transport.delete("$remoteDir/$relPath")
}
onProgress(TransferProgress("complete", uploaded, syncTotal, "已传输: $uploaded 跳过: $uploadSkipped"))
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
err(AppError.Remote("syncToRemote failed: ${e.message}", "sync", cause = e))
}
}
private data class FlatFileInfo(val path: String, val size: Long)
/** Recursively list all files on the remote. Returns null on failure.
* Depth-limited to avoid redundant requests on servers that report
* files as directories or return self-referencing PROPFIND entries. */
private const val MAX_RECURSE_DEPTH = 3
private suspend fun listRemoteRecursive(
transport: RemoteTransport,
remoteDir: String
): List<FlatFileInfo>? {
val result = mutableListOf<FlatFileInfo>()
// Pair of (relativePath, depth)
val dirsToVisit = mutableListOf("" to 0)
while (dirsToVisit.isNotEmpty()) {
val (subDir, depth) = dirsToVisit.removeLast()
if (depth >= MAX_RECURSE_DEPTH) {
Log.w(TAG, "listRemoteRecursive: max depth $MAX_RECURSE_DEPTH reached at $remoteDir/$subDir")
continue
}
val fullDir = if (subDir.isEmpty()) remoteDir else "$remoteDir/$subDir"
val listResult = withRetry("listFiles($fullDir)") {
transport.listFiles(fullDir)
}
if (listResult.isFailure) {
val err = listResult.errorOrNull()
// 404 on a subdirectory: directory doesn't exist, skip it silently.
// 404 on the root directory: fatal — the remote repo path may be wrong.
if (err?.isFileNotFound() == true) {
if (subDir.isEmpty()) {
Log.e(TAG, "listRemoteRecursive: root dir '$fullDir' returned 404 — repo may not exist or is rate-limited")
return null
}
Log.d(TAG, "listRemoteRecursive: $fullDir -> 404, skipping")
continue
}
Log.e(TAG, "listRemoteRecursive: listFiles FAILED for '$fullDir': ${err?.message}")
return null
}
val entries = listResult.getOrThrow()
val parentName = subDir.substringAfterLast("/", subDir)
for (entry in entries) {
val relPath = if (subDir.isEmpty()) entry.name else "$subDir/${entry.name}"
if (entry.isDirectory) {
// Skip self-referencing entries where the server returns
// the directory itself as a child (e.g. data/f9/ contains "f9")
if (entry.name == parentName) {
Log.d(TAG, "listRemoteRecursive skip self-ref: $relPath")
continue
}
dirsToVisit.add(relPath to depth + 1)
} else {
result.add(FlatFileInfo(relPath, entry.size))
}
}
}
Log.i(TAG, "listRemoteRecursive: $remoteDir${result.size} files in ${result.map { it.path }.toSet().size} paths")
return result
}
/** Walk the local directory tree, returning relative-path → File mapping for all files. */
private fun walkLocalFiles(localDir: File): Map<String, File> {
val result = mutableMapOf<String, File>()
val dirsToVisit = mutableListOf(localDir)
val basePath = localDir.absolutePath
while (dirsToVisit.isNotEmpty()) {
val dir = dirsToVisit.removeLast()
for (file in dir.listFiles() ?: emptyArray()) {
if (file.isDirectory) {
dirsToVisit.add(file)
} else {
val relPath = file.absolutePath.removePrefix("$basePath/")
result[relPath] = file
}
}
}
return result
}
}
}
/** Extension to check if an [AppError] represents a "not found" remote error. */
private fun AppError.isFileNotFound(): Boolean =
this is AppError.Remote && this.isNotFound

View File

@@ -0,0 +1,120 @@
package com.example.androidbackupgui.backup
import android.util.Log
import java.io.File
import java.util.UUID
/**
* Manages [ResticRestBridge] lifecycle: create, start, stop, clean cache.
*
* Usage:
* bridgeRunner.withBridge(backend, url, user, pass, share, domain, repoPath) { bridgeUrl, authToken ->
* // RESTIC_REPOSITORY = bridgeUrl
* // RESTIC_REST_USERNAME/PASSWORD = authToken (set via buildBridgeEnv)
* restic commands go here
* }
* // bridge stopped + cache cleaned automatically
* ```
*/
class RestBridgeRunner {
private val TAG = "RestBridgeRunner"
/** Cached transport to reuse SMB sessions across bridge instances. */
private var cachedTransport: RemoteTransport? = null
private var cachedTransportKey: String? = null
/**
* Start a REST bridge for the given [backend], execute [block] with the
* bridge URL, then stop and clean up.
*
* For [backend] == "local", the bridge is not started and [block] receives
* `null`.
*/
suspend fun <T> withBridge(
backend: String,
backendUrl: String,
backendUser: String,
backendPass: String,
backendShare: String,
backendDomain: String,
repoPath: String,
cacheDir: File,
transportFactory: (
backend: String,
url: String,
user: String,
pass: String,
share: String,
domain: String
) -> RemoteTransport? = ::createTransport,
block: suspend (bridgeUrl: String, authToken: String) -> T
): T {
if (backend == "local") {
return block(repoPath, "")
}
val authToken = UUID.randomUUID().toString().replace("-", "").take(32)
val key = "$backend|$backendUrl|$backendUser|$backendPass|$backendShare|$backendDomain"
if (cachedTransportKey != key) {
cachedTransport?.let { Log.d(TAG, "discarding stale cached transport") }
val t = transportFactory(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain)
?: return block(repoPath, "")
cachedTransport = t
cachedTransportKey = key
}
val transport = cachedTransport!!
val remoteBase = buildRemoteBase(backend, backendUrl, backendShare, repoPath)
val bridge = ResticRestBridge(transport, remoteBase, repoPath, cacheDir, authToken)
try {
bridge.start(0)
val port = bridge.listeningPort
if (port < 0) {
throw IllegalStateException("REST bridge failed to bind a port")
}
val bridgeUrl = "rest:http://127.0.0.1:$port/$repoPath"
Log.i(TAG, "REST bridge started on port $port for $remoteBase (auth=${authToken.take(8)}…)")
return block(bridgeUrl, authToken)
} finally {
try {
bridge.stop()
} catch (_: Exception) {}
Log.d(TAG, "REST bridge stopped")
val blobs = cacheDir.listFiles { f -> f.name.startsWith("restic_blob_") }
if (blobs != null) {
for (f in blobs) f.delete()
}
}
}
/** Build the remote base path for the REST bridge. */
private fun buildRemoteBase(
backend: String,
backendUrl: String,
backendShare: String,
repoPath: String
): String {
return when (backend) {
"smb" -> "smb://${backendUrl.trimEnd('/')}/$backendShare/$repoPath"
"webdav" -> "${backendUrl.trimEnd('/')}/${repoPath.trimStart('/')}"
else -> repoPath
}
}
companion object {
/** Default transport factory: delegates to [RemoteTransport.create]. */
fun createTransport(
backend: String,
url: String,
user: String,
pass: String,
share: String,
domain: String
): RemoteTransport? {
return RemoteTransport.create(backend, url, user, pass, share, domain)
}
}
}

View File

@@ -1,27 +1,30 @@
package com.example.androidbackupgui.backup
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
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
* [RemoteSyncManager] which are shared across sub-modules.
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
*/
class ResticBackup(
private val runner: ResticCommandRunner,
private val envResolver: ResticEnvResolver,
private val syncManager: RemoteSyncManager
private val bridgeRunner: RestBridgeRunner,
private val executor: BackendExecutor = BackendExecutor(),
) {
private val TAG = "ResticBackup"
var cacheDir: String = ""
var backendDomain: String = ""
// ── Backup ─────────────────────────────────────────
@@ -36,37 +39,53 @@ class ResticBackup(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
): AppResult<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = true,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
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")
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.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
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 (_: Exception) { /* ignore non-JSON lines */ }
for (tag in tags) {
args.add("--tag")
args.add(tag)
}
if (hostname != null) {
args.add("--host")
args.add(hostname)
}
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@withRemoteSync err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
return@withContext err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
}
parseBackupSummary(result.stdout)
}
}
// ── Internal helpers ───────────────────────────────
@@ -79,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

@@ -33,13 +33,6 @@ object ResticBinary {
}
}
/** Get the temp directory used as local restic repo for remote backends. */
fun getTempRepoDir(context: Context): String {
val dir = File(context.cacheDir, "restic_remote_repo")
dir.mkdirs()
Log.d(TAG, "tempRepoDir = ${dir.absolutePath}")
return dir.absolutePath
}
fun isReady(): Boolean = cachedBinaryPath != null
}

View File

@@ -36,6 +36,22 @@ class ResticCommandRunner {
Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args -> cmd=$cmd")
}
/** Wait for process to exit with a polling loop (compatible with API 24+). */
private fun Process.waitForCompat(deadlineMs: Long = 60_000): Int {
val deadline = System.currentTimeMillis() + deadlineMs
while (System.currentTimeMillis() < deadline) {
try {
return exitValue()
} catch (_: IllegalThreadStateException) {
Thread.sleep(100)
}
}
Log.w(TAG, "process did not exit within ${deadlineMs}ms, destroying")
destroy()
waitFor()
return exitValue()
}
/** Run restic (non-streaming). */
fun runRestic(env: Map<String, String>, args: List<String>): CommandResult {
val cmdArgs = buildCommandArgs(args)
@@ -50,28 +66,23 @@ class ResticCommandRunner {
pb.redirectErrorStream(false)
val 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 stdout = process.inputStream.bufferedReader().use(BufferedReader::readText)
val stderrBytes = process.errorStream.use { it.readAllBytesCompat() }
val exitCode = try {
val deadline = System.currentTimeMillis() + 60_000
var exited = false
while (System.currentTimeMillis() < deadline && !exited) {
try {
process.exitValue()
exited = true
} catch (_: IllegalThreadStateException) {
Thread.sleep(100)
}
}
if (exited) {
process.exitValue()
} else {
Log.w(TAG, "runRestic: process did not exit within 60s, destroying")
process.destroy()
process.waitFor()
process.exitValue()
}
process.waitForCompat()
} catch (_: Exception) { -1 }
try { stderrThread.join(1_000) } catch (_: InterruptedException) {}
val stderrText = stderrBytes.decodeToString()
Log.i(TAG, "runRestic exitCode=$exitCode stdout_len=${stdout.length}")
if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText.trim()}")
@@ -107,44 +118,39 @@ 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()
try {
var line: String
while (reader.readLine().also { line = it } != null) {
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() }
try { stderrThread.join(1_000) } catch (_: InterruptedException) {}
val stderrText = stderrBytes.decodeToString().trim()
val exitCode = try {
// Manual timeout loop (Process.waitFor(timeout,unit) requires API 26+)
val deadline = System.currentTimeMillis() + 60_000
var exited = false
while (System.currentTimeMillis() < deadline && !exited) {
try {
process.exitValue()
exited = true
} catch (_: IllegalThreadStateException) {
Thread.sleep(100)
}
}
if (exited) {
process.exitValue()
} else {
Log.w(TAG, "runResticStreaming: process did not exit within 60s after stdout EOF, destroying")
process.destroy()
process.waitFor()
process.exitValue()
}
process.waitForCompat()
} catch (_: Exception) { -1 }
Log.i(TAG, "runResticStreaming exitCode=$exitCode stdout_len=${stdoutText.length}")
@@ -158,13 +164,14 @@ class ResticCommandRunner {
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,46 +4,60 @@ package com.example.androidbackupgui.backup
* Stateless helper for constructing restic environment variables and repo URLs.
*/
class ResticEnvResolver {
/** Build environment for restic. For SMB/WebDAV backends, uses local temp dir as repo. */
fun buildFullEnv(
repoPath: String,
/** Build environment for non-local backends using the REST bridge URL. */
fun buildBridgeEnv(
password: String,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
tempRepoDir: String = ""
bridgeUrl: String,
cacheDir: String,
authToken: String = "",
): Map<String, String> {
val env = HashMap(System.getenv() ?: emptyMap())
env["RESTIC_REPOSITORY"] = if (backend == "smb" || backend == "webdav") {
tempRepoDir
} else {
buildRepoUrl(backend, repoPath, backendUrl)
}
// 从空白环境开始,不继承系统环境变量(防止敏感信息泄露到子进程)
val env = HashMap<String, String>()
env["RESTIC_REPOSITORY"] = bridgeUrl
env["RESTIC_PASSWORD"] = password
// Restic needs HOME for its cache on Android (no $HOME by default).
// Both local and remote backends use the same cache dir (sibling of tempRepoDir).
if (tempRepoDir.isNotEmpty()) {
val cacheDir = tempRepoDir.substringBeforeLast("/") + "/restic_cache"
if (authToken.isNotEmpty()) {
env["RESTIC_REST_USERNAME"] = authToken
env["RESTIC_REST_PASSWORD"] = authToken
}
if (cacheDir.isNotEmpty()) {
env["HOME"] = cacheDir
env["XDG_CACHE_HOME"] = cacheDir
// Restic needs a writable temp dir for pack files. Android has no /tmp.
val tmpDir = tempRepoDir.substringBeforeLast("/") + "/restic_tmp"
val tmpDir = "$cacheDir/restic_tmp"
env["TMPDIR"] = tmpDir
}
return env
}
/** Build environment for local repository. */
fun buildLocalEnv(
repoPath: String,
password: String,
cacheDir: String,
): Map<String, String> {
// 从空白环境开始,不继承系统环境变量
val env = HashMap<String, String>()
env["RESTIC_REPOSITORY"] = repoPath
env["RESTIC_PASSWORD"] = password
if (cacheDir.isNotEmpty()) {
env["HOME"] = cacheDir
env["XDG_CACHE_HOME"] = cacheDir
val tmpDir = "$cacheDir/restic_tmp"
env["TMPDIR"] = tmpDir
}
return env
}
/** 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,25 +1,69 @@
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 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.
*
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
*
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
* [RemoteSyncManager] which are shared across sub-modules.
* [RestBridgeRunner] which are shared across sub-modules.
*/
class ResticMaintenance(
private val runner: ResticCommandRunner,
private val envResolver: ResticEnvResolver,
private val syncManager: RemoteSyncManager
private val bridgeRunner: RestBridgeRunner,
private val executor: BackendExecutor = BackendExecutor(),
) {
// ── Prune ──────────────────────────────────────────
/** Cache directory for restic env and bridge temp files. Set by [ResticWrapper]. */
var cacheDir: String = ""
/** SMB NTLM domain for remote backend. Set by [ResticWrapper]. */
var backendDomain: String = ""
/** 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,
@@ -29,23 +73,39 @@ class ResticMaintenance(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<String> =
withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = true,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
val result = runner.runRestic(env, "prune")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr))
}
}
runCommand(
"prune",
"restic prune 失败",
repoPath,
password,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
// ── Check ──────────────────────────────────────────
suspend fun unlock(
repoPath: String,
password: String,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
): AppResult<String> =
runCommand(
"unlock",
"restic unlock 失败",
repoPath,
password,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
suspend fun check(
repoPath: String,
@@ -55,23 +115,18 @@ class ResticMaintenance(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<String> =
withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = false,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
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,
@@ -81,19 +136,16 @@ class ResticMaintenance(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<String> =
withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = false,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
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,25 +1,37 @@
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
/**
* Repository lifecycle operations: init and repo URL construction.
*
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
* [RemoteSyncManager] which are shared across sub-modules.
* [RestBridgeRunner] which are shared across sub-modules.
*
* 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.
*/
class ResticRepoInit(
private val runner: ResticCommandRunner,
private val envResolver: ResticEnvResolver,
private val syncManager: RemoteSyncManager
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 = ""
// ── Repository initialization ──────────────────────
suspend fun init(
@@ -30,42 +42,114 @@ class ResticRepoInit(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<Unit> =
withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = true,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
val result = runner.runRestic(env, "init")
// exitCode 0 = brand new repo created, needs upload
if (result.exitCode == 0) {
return@withRemoteSync AppResult.Success(Unit)
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. */
private suspend fun runInit(env: Map<String, String>): AppResult<Unit> {
val result = runner.runRestic(env, "init")
// exitCode 0 = brand new repo created
if (result.exitCode == 0) {
return AppResult.Success(Unit)
}
// exitCode 1: check if it's "config already exists" or a real error
if (result.exitCode == 1) {
if (!isConfigExistsError(result.stderr)) {
// Exit code 1 from restic can also mean connection/backend errors (500, timeout, etc.)
return err(AppError.Restic("restic init 失败: ${result.stderr.take(300).trim()}", result.exitCode, result.stderr))
}
var verify = runner.runRestic(env, "snapshots", "--json")
if (verify.exitCode == 0) {
// Repo is healthy — already initialized with matching password
Log.i(TAG, "init: repo already initialized and verified")
return AppResult.Success(Unit)
}
// Lock-related failure → try unlock then retry
if (isLockError(verify.stderr)) {
Log.w(TAG, "init: stale lock detected, running unlock")
runner.runRestic(env, "unlock")
verify = runner.runRestic(env, "snapshots", "--json")
if (verify.exitCode == 0) {
Log.i(TAG, "init: repo verified after unlock")
return AppResult.Success(Unit)
}
// exitCode 1 = config already exists; verify the repo is actually usable
if (result.exitCode == 1) {
val verify = runner.runRestic(env, "snapshots", "--json")
if (verify.exitCode == 0) {
// Repo is healthy — already initialized with matching password
Log.i(TAG, "init: repo already initialized and verified")
return@withRemoteSync AppResult.Success(Unit)
}
// Config exists but repo is corrupted (wrong password, missing keys, etc.)
return@withRemoteSync err(
AppError.Restic("仓库已存在但无法验证", verify.exitCode, verify.stderr)
)
}
err(AppError.Restic("restic init 失败", result.exitCode, result.stderr))
}
// Config exists but verification failed — diagnose the cause
val detail = diagnoseInitFailure(verify.stderr)
return err(
AppError.Restic("仓库已存在但无法验证: $detail", verify.exitCode, verify.stderr),
)
}
return err(AppError.Restic("restic init 失败", result.exitCode, result.stderr))
}
/** Check if [restic init]'s stderr indicates config already exists (vs a real error). */
private fun isConfigExistsError(stderr: String): Boolean {
val lower = stderr.lowercase()
return lower.contains("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")
}
/** Parse restic stderr to produce a user-facing diagnosis string. */
private fun diagnoseInitFailure(stderr: String): String {
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("permission") || lower.contains("access denied") -> {
"权限不足,请检查目录权限"
}
lower.contains("not a directory") || lower.contains("no such file") -> {
"仓库路径无效或不可访问"
}
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

@@ -0,0 +1,534 @@
package com.example.androidbackupgui.backup
import android.util.Base64
import android.util.Log
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoHTTPD.IHTTPSession
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
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.
*
* Translates restic HTTP requests into [RemoteTransport] calls so that restic
* can read/write blobs directly to SMB/WebDAV without a local staging repo.
*
* Port is auto-assigned (0); use [listeningPort] after start().
*
* @param repoPath repository path from the bridge URL (e.g. "backup").
* Stripped from incoming URIs so that the remoteBase SMB path
* does not get double-nested with the repo prefix.
*/
class ResticRestBridge(
private val transport: RemoteTransport,
private val remoteBase: String,
private val repoPath: String,
private val cacheDir: File,
private val authToken: String = "",
) : NanoHTTPD("127.0.0.1", 0) {
private val TAG = "ResticRestBridge"
init {
cacheDir.mkdirs()
}
@Suppress("DEPRECATION")
override fun serve(session: IHTTPSession): Response {
val uri = session.uri
val method = session.method
val headers = session.headers
val params = session.parms
// 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 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",
)
}
}
Log.d(TAG, "$method $uri")
return try {
handleRequest(method, uri, headers, params, session)
} catch (e: Exception) {
Log.e(TAG, "request failed: $method $uri", e)
newFixedLengthResponse(
Response.Status.INTERNAL_ERROR,
"text/plain",
e.message ?: "Internal error",
)
}
}
private fun handleRequest(
method: NanoHTTPD.Method,
uri: String,
headers: Map<String, String>,
params: Map<String, String>,
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
}
// 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",
)
}
}
}
}
val segments = strippedPath.split("/").filter { it.isNotEmpty() }
if (segments.isEmpty()) {
return newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Invalid path")
}
val firstSegment = segments.first()
// /config endpoints
if (firstSegment == "config" && segments.size == 1) {
return handleConfig(method, headers, session)
}
// /{type}/ or /{type}/{name}
val type = firstSegment
val name = if (segments.size >= 2) segments.drop(1).joinToString("/") else null
if (name == null) {
if (method == NanoHTTPD.Method.GET) {
return handleListBlobs(type)
}
return newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "text/plain", "")
}
return when (method) {
NanoHTTPD.Method.HEAD -> handleHeadBlob(type, name)
NanoHTTPD.Method.GET -> handleGetBlob(type, name, headers)
NanoHTTPD.Method.POST -> handlePostBlob(type, name, session)
NanoHTTPD.Method.DELETE -> handleDeleteBlob(type, name)
else -> newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "text/plain", "")
}
}
// -- 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> {
val started = System.currentTimeMillis()
return try {
val tmpFile = File(tmpDir, "restic_blob_${UUID.randomUUID()}")
val contentLength = session.headers["content-length"]?.toLongOrNull() ?: -1L
val input = (session as NanoHTTPD.HTTPSession).inputStream
Log.d(TAG, "streamBodyToFile: reading body (content-length=$contentLength)...")
tmpFile.outputStream().use { output ->
if (contentLength > 0) {
// Read exactly Content-Length bytes to avoid blocking on keep-alive
val buf = ByteArray(8192)
var remaining = contentLength
while (remaining > 0) {
val toRead = minOf(buf.size.toLong(), remaining).toInt()
val n = input.read(buf, 0, toRead)
if (n == -1) break
output.write(buf, 0, n)
remaining -= n
}
if (remaining > 0) {
Log.w(
TAG,
"streamBodyToFile: body truncated, expected $contentLength bytes but got EOF after ${contentLength - remaining}",
)
}
Unit
} else {
input.copyTo(output)
}
}
val elapsed = System.currentTimeMillis() - started
val bytes = tmpFile.length()
Log.i(TAG, "streamBodyToFile: read $bytes bytes in ${elapsed}ms")
Result.success(tmpFile)
} catch (e: Exception) {
val elapsed = System.currentTimeMillis() - started
Log.w(TAG, "streamBodyToFile failed after ${elapsed}ms", e)
Result.failure(e)
}
}
@Suppress("UNUSED_PARAMETER")
private fun handleConfig(
method: NanoHTTPD.Method,
headers: Map<String, String>,
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 -> {
if (exists.data) {
configExists = true
val sizeResult = transport.fileSize(remotePath)
if (sizeResult is AppResult.Success) configSize = sizeResult.data
}
}
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"}",
)
}
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()
}
}
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)
}
is AppResult.Failure -> {
newFixedLengthResponse(
Response.Status.NOT_FOUND,
"text/plain",
"",
)
}
}
}
@Serializable
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) }
return Json.encodeToString(blobs)
}
// -- 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", "")
}
}
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()
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
}
// 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()
}
}
// -- 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",
)
}
}
} 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",
)
}
}
}
}

View File

@@ -1,27 +1,29 @@
package com.example.androidbackupgui.backup
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
import com.example.androidbackupgui.backup.AppError
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.err
/**
* 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
* [RemoteSyncManager] which are shared across sub-modules.
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
*/
class ResticRestore(
private val runner: ResticCommandRunner,
private val envResolver: ResticEnvResolver,
private val syncManager: RemoteSyncManager
private val bridgeRunner: RestBridgeRunner,
private val executor: BackendExecutor = BackendExecutor(),
) {
var cacheDir: String = ""
var backendDomain: String = ""
// ── Restore ────────────────────────────────────────
suspend fun restore(
@@ -35,42 +37,60 @@ class ResticRestore(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
onProgress: suspend (String) -> Unit = {}
): AppResult<Unit> = withContext(Dispatchers.IO) {
val emit: suspend (String) -> Unit = { s -> withContext(Dispatchers.Main) { onProgress(s) } }
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = false,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
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) }
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
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 (_: Exception) { emit(line) }
if (include != null) {
args.add("--include")
args.add(include)
}
if (result.exitCode == 0) AppResult.Success(Unit)
else err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr))
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)
}
}
}
if (result.exitCode == 0) {
AppResult.Success(Unit)
} else {
err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr))
}
}
}
// ── File dump ──────────────────────────────────────
@@ -84,18 +104,28 @@ class ResticRestore(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<String> = withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = false,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
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))
): 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,25 +1,27 @@
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Snapshot listing and retention policy operations.
*
* [listSnapshots] is download-only; [forget] requires both download and upload
* (forget removes snapshots from the remote).
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
* [RemoteSyncManager] which are shared across sub-modules.
* [listSnapshots] is download-only; [forget] removes snapshots from the remote.
*
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
*/
class ResticSnapshotOps(
private val runner: ResticCommandRunner,
private val envResolver: ResticEnvResolver,
private val syncManager: RemoteSyncManager
private val bridgeRunner: RestBridgeRunner,
private val executor: BackendExecutor = BackendExecutor(),
) {
var cacheDir: String = ""
var backendDomain: String = ""
// ── List snapshots ─────────────────────────────────
suspend fun listSnapshots(
@@ -31,34 +33,44 @@ class ResticSnapshotOps(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<List<ResticWrapper.ResticSnapshot>> = withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = false,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
): 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.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
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@withRemoteSync err(AppError.Restic("restic snapshots 失败", result.exitCode, result.stderr))
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) {
} catch (e: Exception) {
err(AppError.Parse("解析快照 JSON 失败", e.message ?: ""))
}
}
}
// ── Forget (retention policy) ──────────────────────
@@ -74,27 +86,40 @@ class ResticSnapshotOps(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<String> = withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = true,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
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.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
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))
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,16 +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 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.
@@ -18,60 +19,86 @@ import com.example.androidbackupgui.backup.err
* Uses environment variables (RESTIC_REPOSITORY, RESTIC_PASSWORD) rather than
* command-line flags to avoid leaking secrets in the process list.
*
* For SMB/WebDAV backends, restic runs against a local temp directory;
* RemoteTransport syncs files to/from the remote backend.
* For SMB/WebDAV backends, restic connects via a local REST bridge
* ([ResticRestBridge]) that translates HTTP requests to [RemoteTransport] calls,
* eliminating the need for a local staging repo and full-directory sync.
*
* All public methods are suspend and run on Dispatchers.IO.
*
* This object is a facade that delegates to [ResticCommandRunner],
* [ResticEnvResolver], [RemoteSyncManager], and sub-module classes
* [ResticEnvResolver], [RestBridgeRunner], and sub-module classes
* ([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 syncManager = RemoteSyncManager()
/**
* 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, syncManager)
private val backupOp = ResticBackup(runner, envResolver, syncManager)
private val restoreOp = ResticRestore(runner, envResolver, syncManager)
private val snapshotOps = ResticSnapshotOps(runner, envResolver, syncManager)
private val maintenance = ResticMaintenance(runner, envResolver, syncManager)
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
}
/** Local temp directory used as restic repo for SMB/WebDAV backends. */
var tempRepoDir: String
get() = syncManager.tempRepoDir
set(v) { syncManager.tempRepoDir = v }
/** Domain for SMB NTLM authentication. */
var backendDomain: String
get() = syncManager.backendDomain
set(v) { syncManager.backendDomain = v }
/** Cache directory for restic (XDG_CACHE_HOME) and bridge tmp blobs. */
var cacheDir: String = ""
set(v) {
field = v
repoInit.cacheDir = v
backupOp.cacheDir = v
restoreOp.cacheDir = v
snapshotOps.cacheDir = v
maintenance.cacheDir = v
}
/** Domain for SMB NTLM authentication. Propagated to sub-modules. */
var backendDomain: String = ""
set(v) {
field = v
repoInit.backendDomain = v
backupOp.backendDomain = v
restoreOp.backendDomain = v
snapshotOps.backendDomain = v
maintenance.backendDomain = v
}
// ── Progress data ─────────────────────────────────
@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
@@ -81,7 +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(),
)
// ── Repository lifecycle ─────────────────────────
@@ -94,12 +128,16 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<Unit> = repoInit.init(
repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
)
): AppResult<Unit> =
repoInit.init(
repoPath,
password,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
// ── Backup ─────────────────────────────────────────
@@ -118,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(
@@ -132,14 +170,62 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
onProgress: suspend (ResticProgress) -> Unit = {}
): AppResult<BackupSummary> = backupOp.backup(
repoPath, password, paths, tags, hostname,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress, onProgress
)
onProgress: suspend (ResticProgress) -> Unit = {},
): AppResult<BackupSummary> =
backupOp.backup(
repoPath,
password,
paths,
tags,
hostname,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
onProgress,
)
/**
* 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,
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 ────────────────────────────────────────
@@ -154,14 +240,21 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
onProgress: suspend (String) -> Unit = {}
): AppResult<Unit> = restoreOp.restore(
repoPath, password, snapshotId, targetPath, include,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress, onProgress
)
onProgress: suspend (String) -> Unit = {},
): AppResult<Unit> =
restoreOp.restore(
repoPath,
password,
snapshotId,
targetPath,
include,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
onProgress,
)
// ── File dump ──────────────────────────────────────
@@ -175,13 +268,18 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<String> = restoreOp.dump(
repoPath, password, snapshotId, filePath,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
)
): AppResult<String> =
restoreOp.dump(
repoPath,
password,
snapshotId,
filePath,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
// ── Snapshot management ────────────────────────────
@@ -194,13 +292,17 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<List<ResticSnapshot>> = snapshotOps.listSnapshots(
repoPath, password, tag,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
)
): AppResult<List<ResticSnapshot>> =
snapshotOps.listSnapshots(
repoPath,
password,
tag,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
suspend fun forget(
repoPath: String,
@@ -214,13 +316,117 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<String> = snapshotOps.forget(
repoPath, password, keepDaily, keepWeekly, keepMonthly, dryRun,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
)
): 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
* of package-name → [SnapshotAppInfo]. Returns `null` when no snapshots
* exist or the file cannot be read (e.g. first backup, legacy format).
*/
suspend fun getLatestSnapshotAppDetails(
repoPath: String,
password: String,
backend: String = "local",
backendUrl: String = "",
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
if (snaps.isEmpty()) return@withContext null
val latestId = snaps.first().shortId
val basePath =
snaps
.first()
.paths
.firstOrNull()
?.trimEnd('/') ?: return@withContext null
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)
}
/** Parse [app_details.json] content into a package-name → [SnapshotAppInfo] map. */
internal fun parseAppDetailsJson(jsonStr: String): Map<String, SnapshotAppInfo> {
val map = mutableMapOf<String, SnapshotAppInfo>()
try {
val root = JSONObject(jsonStr)
for (key in root.keys()) {
val entry = root.optJSONObject(key) ?: continue
val sizes = mutableListOf<Long>()
val sizesArr = entry.optJSONArray("apkSizes")
if (sizesArr != null) {
for (i in 0 until sizesArr.length()) {
sizes.add(sizesArr.optLong(i, 0L))
}
}
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")
}
return map
}
// ── Maintenance ────────────────────────────────────
@@ -232,13 +438,16 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<String> = maintenance.prune(
repoPath, password,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
)
): AppResult<String> =
maintenance.prune(
repoPath,
password,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
suspend fun check(
repoPath: String,
@@ -248,13 +457,16 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<String> = maintenance.check(
repoPath, password,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
)
): AppResult<String> =
maintenance.check(
repoPath,
password,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
suspend fun stats(
repoPath: String,
@@ -264,28 +476,42 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<String> = maintenance.stats(
repoPath, password,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
)
): AppResult<String> =
maintenance.stats(
repoPath,
password,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
suspend fun unlock(
repoPath: String,
password: String,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
): AppResult<String> =
maintenance.unlock(
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)
}
// ── Lifecycle ──────────────────────────────────────
/**
* Public safety-net cleanup called by fragment lifecycle.
* Waits for any in-progress operation to finish, then deletes temp dirs.
*/
suspend fun cleanup() {
syncManager.cleanup()
}
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.coroutineScope
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)
coroutineScope {
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,40 +404,85 @@ object RestoreOperation {
}
if (!result.isSuccess) return false
return !result.output.lines().any { line ->
val path = line.substringBefore(" -> ")
val hasTraversal = path.trimStart('/').split("/").any { segment -> segment == ".." }
val symlinkTarget = if (" -> " in line) line.substringAfter(" -> ") else ""
val unsafeSymlink = symlinkTarget.isNotEmpty() &&
(symlinkTarget.startsWith("/") || symlinkTarget.split("/").any { segment -> segment == ".." })
hasTraversal || unsafeSymlink
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}")
}
}
@@ -338,24 +490,117 @@ 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,
// not the double-quoted sed string).
if (!ssaidValue.matches(Regex("^[0-9a-fA-F]+$"))) {
Log.w(TAG, "restoreSsaid: ssaid value is not hex, skipping XML edit for $packageName")
return
}
// 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")
@@ -364,44 +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()
if (id.length != 36) { // UUID format check
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) {
@@ -414,26 +664,26 @@ 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
val pkgEsc = packageName.shellEscape()
// Reset app ops first (clears any previous modes)
RootShell.exec("appops reset '$pkgEsc' 2>/dev/null")
// NOTE: Intentionally skipping "appops reset" because we don't capture
// app ops state (battery optimization, notification settings, etc.)
// in the backup. Resetting would lose those user customizations.
val grantedPerms = parsedPerms.filter { it.second }.map { it.first }
val deniedPerms = parsedPerms.filter { !it.second }.map { it.first }
@@ -462,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()
@@ -499,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

@@ -0,0 +1,44 @@
package com.example.androidbackupgui.backup
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
/**
* Retry [block] up to [maxRetries] times with exponential backoff.
* Propagates [CancellationException] immediately.
* Returns the first [AppResult.Success], or the last [AppResult.Failure] after all retries.
*/
suspend fun <T> retryWithBackoff(
tag: String,
operation: String,
maxRetries: Int = 3,
initialDelayMs: Long = 1000,
block: suspend () -> AppResult<T>
): AppResult<T> {
var lastError: AppResult.Failure? = null
repeat(maxRetries) { attempt ->
try {
val result = block()
if (result is AppResult.Success) return result
lastError = result as AppResult.Failure
if (attempt < maxRetries - 1) {
val delayMs = initialDelayMs * (1L shl attempt)
Log.w(tag, "$operation 失败 (第${attempt+1}次), ${maxRetries-attempt-1}次重试剩余, 等待${delayMs}ms: ${result.error.message}")
delay(delayMs)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
if (attempt < maxRetries - 1) {
val delayMs = initialDelayMs * (1L shl attempt)
Log.e(tag, "$operation 异常 (第${attempt+1}次), ${maxRetries-attempt-1}次重试剩余", e)
delay(delayMs)
} else {
Log.e(tag, "$operation 最终失败", e)
return err(AppError.Remote("$operation 失败 (重试${maxRetries}次后)", operation, cause = e))
}
}
}
return lastError ?: err(AppError.Remote("$operation 失败", operation))
}

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import java.io.File
import java.util.Properties
import java.util.concurrent.atomic.AtomicBoolean
class SmbTransport(
private val host: String,
@@ -22,10 +23,21 @@ class SmbTransport(
private val password: String,
private val domain: String = "",
private val bufferSize: Int = 8192,
private val smbSigning: Boolean = true
private val smbSigning: Boolean = false
): RemoteTransport {
companion object { private const val TAG = "SmbTransport" }
companion object {
private const val TAG = "SmbTransport"
/** Register missing JCA algorithms for jcifs-ng (MD4, AESCMAC, etc.). */
private val patchesRegistered = AtomicBoolean(false)
fun registerMissingAlgorithms() {
if (patchesRegistered.compareAndSet(false, true)) {
MissingAlgoProvider.register()
}
}
}
private val context: CIFSContext by lazy {
registerMissingAlgorithms()
val props = Properties().apply {
// Force SMB 2.0.2 minimum — SMB1 is disabled on modern Windows
setProperty("jcifs.smb.client.minVersion", "SMB202")
@@ -33,7 +45,7 @@ class SmbTransport(
// Shorter timeouts for Android
setProperty("jcifs.smb.client.responseTimeout", "15000")
setProperty("jcifs.smb.client.connTimeout", "10000")
// Enable SMB signing for security (prevents tampering) — disable for legacy servers
// SMB signing (disabled by default — most home servers don't support it)
if (smbSigning) {
setProperty("jcifs.smb.client.signingEnabled", "true")
setProperty("jcifs.smb.client.encryptionEnabled", "true")
@@ -47,7 +59,9 @@ class SmbTransport(
}
}
/** Build a full SMB URL. If [path] is already a full URL, pass through. */
private fun buildUrl(path: String): String {
if (path.startsWith("smb://")) return path
val cleanPath = path.trimStart('/')
val sharePath = if (share.isNotEmpty()) "$share/$cleanPath" else cleanPath
return "smb://$host/$sharePath"
@@ -56,45 +70,53 @@ class SmbTransport(
private fun smbFile(path: String): SmbFile = SmbFile(buildUrl(path), context)
override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
withContext(Dispatchers.IO) {
try {
val localFile = File(localPath)
val remote = smbFile(remotePath)
// Ensure parent directories exist (parent can be null at share root)
val parentPath = remote.parent
if (parentPath != null) {
val parent = SmbFile(parentPath, context)
if (!parent.exists()) parent.mkdirs()
}
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
val fileSize = localFile.length()
SmbFileOutputStream(remote).use { output ->
localFile.inputStream().use { input ->
onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath))
val buffer = ByteArray(bufferSize)
var totalRead = 0L
var n = input.read(buffer)
while (n != -1) {
output.write(buffer, 0, n)
totalRead += n
onByteProgress(RemoteTransport.ByteProgress(totalRead, fileSize, remotePath))
n = input.read(buffer)
retryWithBackoff(TAG, "SMB 上传") {
withContext(Dispatchers.IO) {
try {
val localFile = File(localPath)
val remote = smbFile(remotePath)
val parentPath = remote.parent
if (parentPath != null) {
val parent = SmbFile(parentPath, context)
if (!parent.exists()) parent.mkdirs()
}
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
val fileSize = localFile.length()
SmbFileOutputStream(remote).use { output ->
localFile.inputStream().use { input ->
onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath))
val buffer = ByteArray(bufferSize)
var totalRead = 0L
var n = input.read(buffer)
while (n != -1) {
output.write(buffer, 0, n)
totalRead += n
onByteProgress(RemoteTransport.ByteProgress(totalRead, fileSize, remotePath))
n = input.read(buffer)
}
}
}
val freshRemote = SmbFile(buildUrl(remotePath), context)
val actualSize = freshRemote.length()
Log.i(TAG, "upload done: $fileSize bytes local, $actualSize bytes on SMB")
if (actualSize != fileSize) {
Log.e(TAG, "upload size mismatch: local=$fileSize smb=$actualSize")
return@withContext err(AppError.Remote("SMB 上传大小不匹配", "upload"))
}
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "upload failed: ${buildUrl(remotePath)}", e)
err(AppError.Remote("SMB 上传失败", "upload", cause = e))
}
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
Log.i(TAG, "upload $localPath -> ${buildUrl(remotePath)} ($fileSize bytes)")
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "upload failed: ${buildUrl(remotePath)}", e)
err(AppError.Remote("SMB 上传失败", "upload", cause = e))
}
}
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
withContext(Dispatchers.IO) {
retryWithBackoff(TAG, "SMB 下载") {
withContext(Dispatchers.IO) {
try {
val localFile = File(localPath)
localFile.parentFile?.mkdirs()
@@ -125,6 +147,7 @@ class SmbTransport(
err(AppError.Remote("SMB 下载失败", "download", cause = e))
}
}
}
override suspend fun listFiles(remoteDir: String): AppResult<List<RemoteTransport.RemoteFileInfo>> =
withContext(Dispatchers.IO) {
@@ -184,15 +207,15 @@ class SmbTransport(
} catch (e: SmbException) {
// STATUS_OBJECT_NAME_COLLISION (0xC0000035): directory already exists — not an error
if (e.ntStatus == 0xC0000035.toInt()) {
AppResult.Success(Unit)
AppResult.Success(Unit)
} else {
Log.e(TAG, "mkdirs failed: $remotePath${e.message}")
Log.e(TAG, "mkdirs failed: $remotePathntStatus=0x${e.ntStatus.toString(16)} msg=${e.message} cause=${e.cause}")
err(AppError.Remote("SMB 创建目录失败", "mkdirs", cause = e))
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "mkdirs failed: $remotePath${e.message}")
Log.e(TAG, "mkdirs failed: $remotePath${e::class.java.name}: ${e.message} cause=${e.cause?.message}")
err(AppError.Remote("SMB 创建目录失败", "mkdirs", cause = e))
}
}
@@ -229,4 +252,17 @@ class SmbTransport(
err(AppError.Remote("SMB 检查失败", "exists", cause = e))
}
}
override suspend fun fileSize(remotePath: String): AppResult<Long> =
withContext(Dispatchers.IO) {
try {
val file = smbFile(remotePath)
if (!file.exists()) return@withContext err(AppError.Remote("文件不存在", "fileSize"))
AppResult.Success(file.length())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
err(AppError.Remote("SMB 获取文件大小失败", "fileSize", cause = e))
}
}
}

View File

@@ -7,20 +7,30 @@ import com.thegrizzlylabs.sardineandroid.impl.SardineException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import android.util.Base64
import java.net.HttpURLConnection
import java.net.URL
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
class WebdavTransport(
private val baseUrl: String,
private val username: String,
private val password: String,
private val bufferSize: Int = 8192
private val bufferSize: Int = 8192,
private val connectTimeoutSeconds: Int = 15,
private val readTimeoutSeconds: Int = 30
): RemoteTransport {
companion object { private const val TAG = "WebdavTransport" }
private val sardine: Sardine by lazy {
OkHttpSardine().apply {
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(connectTimeoutSeconds.toLong(), java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(readTimeoutSeconds.toLong(), java.util.concurrent.TimeUnit.SECONDS)
.build()
OkHttpSardine(client).apply {
if (username.isNotEmpty()) {
setCredentials(username, password)
}
@@ -33,73 +43,138 @@ class WebdavTransport(
}
override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
withContext(Dispatchers.IO) {
try {
val url = buildUrl(remotePath)
val file = File(localPath)
val fileSize = file.length()
if (fileSize > 50 * 1024 * 1024L) {
return@withContext err(AppError.Remote("WebDAV 上传: 文件过大 (${fileSize / 1024 / 1024}MB), 上限 50MB", "upload"))
}
Log.d(TAG, "upload $localPath -> $url ($fileSize bytes)")
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
// Read file into ByteArray with progress (sardine.put lacks InputStream variant)
val data = file.inputStream().buffered(bufferSize).use { input ->
onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath))
val out = ByteArrayOutputStream()
val buffer = ByteArray(bufferSize)
var totalRead = 0L
var n = input.read(buffer)
while (n != -1) {
out.write(buffer, 0, n)
totalRead += n
onByteProgress(RemoteTransport.ByteProgress(totalRead, fileSize, remotePath))
n = input.read(buffer)
retryWithBackoff(TAG, "WebDAV 上传") {
withContext(Dispatchers.IO) {
try {
val url = buildUrl(remotePath)
val file = File(localPath)
val fileSize = file.length()
if (fileSize > 50 * 1024 * 1024L) {
return@withContext err(AppError.Remote("WebDAV 上传: 文件过大 (${fileSize / 1024 / 1024}MB), 上限 50MB", "upload"))
}
out.toByteArray()
}
sardine.put(url, data, "application/octet-stream")
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "upload failed: $remotePath", e)
err(AppError.Remote("WebDAV 上传失败", "upload", cause = e))
}
}
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
withContext(Dispatchers.IO) {
try {
val url = buildUrl(remotePath)
val localFile = File(localPath)
localFile.parentFile?.mkdirs()
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
sardine.get(url).use { input ->
localFile.outputStream().use { output ->
Log.d(TAG, "upload $localPath -> $url ($fileSize bytes)")
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
val data = file.inputStream().buffered(bufferSize).use { input ->
onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath))
val out = ByteArrayOutputStream()
val buffer = ByteArray(bufferSize)
var totalRead = 0L
var n = input.read(buffer)
while (n != -1) {
output.write(buffer, 0, n)
out.write(buffer, 0, n)
totalRead += n
onByteProgress(RemoteTransport.ByteProgress(totalRead, 0, remotePath))
onByteProgress(RemoteTransport.ByteProgress(totalRead, fileSize, remotePath))
n = input.read(buffer)
}
out.toByteArray()
}
sardine.put(url, data, "application/octet-stream")
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "upload failed: $remotePath", e)
err(AppError.Remote("WebDAV 上传失败", "upload", cause = e))
}
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
Log.d(TAG, "download $url -> $localPath (${localFile.length()} bytes)")
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "download failed: $remotePath", e)
err(AppError.Remote("WebDAV 下载失败", "download", cause = e))
}
}
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
retryWithBackoff(TAG, "WebDAV 下载") {
withContext(Dispatchers.IO) {
try {
val url = buildUrl(remotePath)
val localFile = File(localPath)
localFile.parentFile?.mkdirs()
val partFile = File(localPath + ".part")
val existingBytes = if (partFile.exists()) partFile.length() else 0L
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
if (existingBytes > 0L) {
Log.d(TAG, "download 发现 .part 文件, 从 offset=$existingBytes 续传: $remotePath")
downloadRangeResume(url, partFile, existingBytes, onByteProgress, remotePath)
} else {
sardine.get(url).use { input ->
partFile.outputStream().use { output ->
onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath))
val buffer = ByteArray(bufferSize)
var totalRead = 0L
var n = input.read(buffer)
while (n != -1) {
output.write(buffer, 0, n)
totalRead += n
onByteProgress(RemoteTransport.ByteProgress(totalRead, 0, remotePath))
n = input.read(buffer)
}
}
}
}
if (partFile.exists()) {
partFile.renameTo(localFile)
}
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
Log.d(TAG, "download $url -> $localPath (${localFile.length()} bytes)")
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "download failed: $remotePath", e)
err(AppError.Remote("WebDAV 下载失败", "download", cause = e))
}
}
} // retryWithBackoff
/**
* Resume a partial WebDAV download using HTTP Range header.
* Reads from [partFile] which already has [offset] bytes, requests remaining bytes via
* [HttpURLConnection] with Basic auth, and appends to the file.
*/
private suspend fun downloadRangeResume(
url: String,
partFile: File,
offset: Long,
onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit,
remotePath: String
) {
val conn = URL(url).openConnection() as HttpURLConnection
try {
conn.requestMethod = "GET"
if (username.isNotEmpty()) {
val basicAuth = "Basic " + Base64.encodeToString(
"$username:$password".toByteArray(Charsets.UTF_8),
Base64.NO_WRAP
)
conn.setRequestProperty("Authorization", basicAuth)
}
conn.setRequestProperty("Range", "bytes=$offset-")
conn.connect()
val statusCode = conn.responseCode
if (statusCode != 206 && statusCode != 200) {
throw IOException("WebDAV Range resume 失败: HTTP $statusCode (需要 206)")
}
val totalSize = offset + conn.contentLength
java.io.FileOutputStream(partFile, true).use { output ->
conn.inputStream.use { input ->
val buffer = ByteArray(bufferSize)
var totalRead = offset
var n = input.read(buffer)
while (n != -1) {
output.write(buffer, 0, n)
totalRead += n
onByteProgress(RemoteTransport.ByteProgress(totalRead, totalSize, remotePath))
n = input.read(buffer)
}
}
}
} finally {
conn.disconnect()
}
}
override suspend fun listFiles(remoteDir: String): AppResult<List<RemoteTransport.RemoteFileInfo>> =
withContext(Dispatchers.IO) {
try {
@@ -150,8 +225,8 @@ class WebdavTransport(
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "mkdirs failed: $remotePath${e.message}")
AppResult.Success(Unit) // best-effort; upload will fail if dir can't be created
Log.e(TAG, "mkdirs failed: $remotePath${e.message}")
err(AppError.Remote("WebDAV mkdirs 失败", "mkdirs", cause = e))
}
}
@@ -180,4 +255,19 @@ class WebdavTransport(
err(AppError.Remote("WebDAV 检查失败", "exists", cause = e))
}
}
override suspend fun fileSize(remotePath: String): AppResult<Long> =
withContext(Dispatchers.IO) {
try {
val url = buildUrl(remotePath)
if (!sardine.exists(url)) return@withContext err(AppError.Remote("文件不存在", "fileSize"))
val resources = sardine.list(url)
val size = resources.firstOrNull()?.contentLength ?: 0L
AppResult.Success(size)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
err(AppError.Remote("WebDAV 获取文件大小失败", "fileSize", cause = e))
}
}
}

View File

@@ -4,6 +4,7 @@ import android.util.Log
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
@@ -49,21 +50,32 @@ object RootShell {
}
}
/** Call once at app startup to configure libsu. */
/** Call once at app startup to configure libsu. Safe to call multiple times. */
fun configure() {
Shell.enableVerboseLogging = true
Shell.setDefaultBuilder(
Shell.Builder.create()
.setFlags(Shell.FLAG_MOUNT_MASTER)
.setInitializers(GlobalNamespaceInitializer::class.java)
.setTimeout(30)
)
try {
Shell.setDefaultBuilder(
Shell.Builder.create()
.setFlags(Shell.FLAG_MOUNT_MASTER)
.setInitializers(GlobalNamespaceInitializer::class.java)
.setTimeout(30)
)
} catch (_: IllegalStateException) {
// Shell already created (e.g. from Application superclass or prior session).
// The default builder is already in effect — our custom config is ignored
// but the shell is still functional.
} catch (e: Exception) {
// Some ROMs throw other exceptions during root init; don't crash startup.
Log.w(TAG, "configure: failed to set default builder", e)
}
}
suspend fun ensureSession(): Boolean = withContext(Dispatchers.IO) {
try {
Shell.getShell().isRoot
} catch (_: Exception) { false }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { false }
}
suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult =
@@ -81,6 +93,8 @@ object RootShell {
} catch (e: TimeoutCancellationException) {
Log.w(TAG, "exec timeout (${timeoutMs}ms): $command")
ShellResult("", "Command timed out after ${timeoutMs}ms", -1)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "exec failed: $command", e)
ShellResult("", e.message ?: "Unknown error", -1)

View File

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

View File

@@ -0,0 +1,69 @@
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.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
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, "配置"),
)
private data class NavItem(
val screen: Screen,
val icon: ImageVector,
val label: String
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppScaffold() {
var currentScreen by remember { mutableStateOf(Screen.CONFIG) }
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text(currentScreen.label) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
)
)
},
bottomBar = {
NavigationBar {
navItems.forEach { item ->
NavigationBarItem(
selected = currentScreen == item.screen,
onClick = { currentScreen = item.screen },
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
)
}
}
}
) { innerPadding ->
Surface(
modifier = Modifier.padding(innerPadding),
color = MaterialTheme.colorScheme.background
) {
when (currentScreen) {
Screen.BACKUP -> BackupScreen()
Screen.RESTORE -> RestoreScreen()
Screen.LOG -> LogScreen()
Screen.CONFIG -> ConfigScreen(snackbarHostState = snackbarHostState)
}
}
}
}

View File

@@ -1,324 +0,0 @@
package com.example.androidbackupgui.ui
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import androidx.core.content.ContextCompat
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.androidbackupgui.backup.AppInfo
import com.example.androidbackupgui.backup.AppScanner
import com.example.androidbackupgui.backup.BackupConfig
import com.example.androidbackupgui.backup.BackupOperation
import com.example.androidbackupgui.backup.BackupService
import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.ResticWrapper
import com.example.androidbackupgui.backup.WifiManager
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.RemoteTransport
import com.example.androidbackupgui.databinding.FragmentBackupBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.example.androidbackupgui.backup.formatSize
import java.io.File
import java.util.Locale
class BackupFragment : Fragment() {
private var _binding: FragmentBackupBinding? = null
private val binding get() = _binding!!
private var apps: List<AppInfo> = emptyList()
private var selectedApps = mutableSetOf<String>()
private var sortedApps: List<AppInfo> = emptyList()
private lateinit var config: BackupConfig
private var selectedUserId: Int = 0
private var userList: List<Pair<Int, String>> = listOf(0 to "Owner")
private var sortMode: SortMode = SortMode.NAME_ASC
private var showSystemApps: Boolean = false
private enum class SortMode { NAME_ASC, SIZE_DESC }
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentBackupBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val configFile = File(requireContext().filesDir, "backup_settings.conf")
config = BackupConfig.fromFile(configFile)
binding.appList.layoutManager = LinearLayoutManager(requireContext())
binding.scanButton.setOnClickListener { scanApps() }
binding.backupButton.setOnClickListener { startBackup() }
// Sort/filter controls
binding.sortAZButton.setOnClickListener {
sortMode = SortMode.NAME_ASC
applySortFilter()
}
binding.sortSizeButton.setOnClickListener {
sortMode = SortMode.SIZE_DESC
applySortFilter()
}
binding.selectAllButton.setOnClickListener {
selectedApps.addAll(apps.map { it.packageName.value })
applySortFilter()
}
binding.deselectAllButton.setOnClickListener {
selectedApps.clear()
applySortFilter()
}
binding.showSystemSwitch.setOnCheckedChangeListener { _, checked ->
showSystemApps = checked
applySortFilter()
}
// Load user profiles and setup dropdown
loadUsers()
}
private fun loadUsers() {
viewLifecycleOwner.lifecycleScope.launch {
try {
userList = AppScanner.enumerateUsers()
val names = userList.map { (id, name) -> "$name (ID: $id)" }
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.userSelector.adapter = adapter
binding.userSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedUserId = userList.getOrNull(position)?.first ?: 0
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
} catch (e: Exception) {
binding.statusText.text = "加载用户失败: ${e.message}"
}
}
}
override fun onResume() {
super.onResume()
}
private fun scanApps() {
binding.backupButton.isEnabled = false
setRunning(true)
binding.statusText.text = "正在扫描应用…"
viewLifecycleOwner.lifecycleScope.launch {
try {
val ctx = requireContext()
val thirdParty = AppScanner.scanThirdParty(ctx, userId = selectedUserId)
val system = AppScanner.scanSystem(ctx, config, userId = selectedUserId)
apps = if (showSystemApps) thirdParty + system else thirdParty
selectedApps.clear()
selectedApps.addAll(apps.map { it.packageName.value })
binding.statusText.text = "共找到 ${apps.size} 个应用,全部已选中"
binding.backupButton.isEnabled = apps.isNotEmpty()
setRunning(false)
applySortFilter()
} catch (e: Exception) {
binding.statusText.text = "扫描应用失败: ${e.message}"
setRunning(false)
binding.backupButton.isEnabled = false
}
}
}
private fun applySortFilter() {
var filtered = if (showSystemApps) apps else apps.filter { !it.isSystem }
filtered = when (sortMode) {
SortMode.NAME_ASC -> filtered.sortedBy { it.label.lowercase(Locale.US) }
SortMode.SIZE_DESC -> filtered.sortedByDescending { it.backupSize }
}
sortedApps = filtered
setupAppList()
binding.statusText.text = "已选择 ${selectedApps.size}/${sortedApps.size} 个应用"
}
private fun setupAppList() {
val displayApps = sortedApps.ifEmpty { apps }
binding.appList.adapter = PackageListAdapter(displayApps, selectedApps) { pkg, checked ->
if (checked) selectedApps.add(pkg) else selectedApps.remove(pkg)
binding.statusText.text = "已选择 ${selectedApps.size}/${displayApps.size} 个应用"
}
}
private fun startBackup() {
val toBackup = apps.filter { it.packageName.value in selectedApps }
if (toBackup.isEmpty()) return
// Check restic local repo availability before doing any work
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank() &&
config.resticBackend == "local" && !File(config.resticRepo, "config").exists()
) {
binding.statusText.text = "restic 本地仓库未初始化,请先在设置中初始化"
return
}
setRunning(true)
binding.backupButton.isEnabled = false
binding.scanButton.isEnabled = false
// Start foreground service to keep process alive
val serviceIntent = Intent(requireContext(), BackupService::class.java)
serviceIntent.action = BackupService.ACTION_START_BACKUP
serviceIntent.putExtra(BackupService.EXTRA_STATUS_TEXT, "正在备份 ${toBackup.size} 个应用…")
try {
ContextCompat.startForegroundService(requireContext(), serviceIntent)
} catch (_: Exception) {}
viewLifecycleOwner.lifecycleScope.launch {
try {
val outputDir = File(config.outputPath.ifEmpty {
requireContext().filesDir.absolutePath
})
val result = BackupOperation.backupApps(
context = requireContext(),
apps = toBackup,
config = config,
outputDir = outputDir,
userId = selectedUserId.toString(),
onProgress = { progress ->
val label = toBackup.find { it.packageName.value == progress.packageName }?.label
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
updateStatus("[${progress.current}/${progress.total}] $name: ${progress.message}")
}
)
// Store WiFi config inside Backup_* directory so restic/local restore can find it
WifiManager.backup(File(result.outputDir))
// If restic is enabled, snapshot to repository
var resticSummary: ResticWrapper.BackupSummary? = null
var resticError: String? = null
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
val binaryPath = ResticBinary.prepare(requireContext())
if (binaryPath != null) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
ResticWrapper.backendDomain = config.resticBackendDomain
if (config.resticBackend == "local") {
if (!File(config.resticRepo, "config").exists()) {
updateStatus("restic 本地仓库未初始化,请先在设置中初始化")
return@launch
}
}
updateStatus("正在写入 restic 去重仓库…")
val resticResult = ResticWrapper.backup(
repoPath = config.resticRepo,
password = config.resticPassword,
paths = listOf(result.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,
onSyncProgress = { progress: RemoteTransport.TransferProgress ->
if (progress.phase in listOf("list", "download", "upload", "delete_stale")) {
updateStatus("同步中: ${progress.current}/${progress.total} 个文件")
}
},
onByteSyncProgress = { progress ->
withContext(Dispatchers.Main) {
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
binding.progressBar.progress = progress.bytesTransferred.toInt()
}
updateStatus("同步中: ${progress.currentFile}\n" +
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}")
},
onProgress = { progress ->
if (progress.messageType == "status") {
updateStatus("去重仓库: %.0f%% (%d/%d 个文件)".format(
progress.percentDone * 100,
progress.filesDone,
progress.totalFiles
))
}
}
)
when (resticResult) {
is AppResult.Success -> resticSummary = resticResult.data
is AppResult.Failure -> {
resticError = resticResult.error.message
updateStatus("restic 快照失败: ${resticResult.error.message}")
}
}
}
}
updateStatus(buildString {
appendLine("备份完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
appendLine("耗时: ${result.elapsedMs / 1000}")
appendLine("输出: ${result.outputDir}")
val summary = resticSummary
if (summary != null) {
appendLine()
appendLine("── Restic 快照 ──")
appendLine("ID: ${summary.snapshotId.take(8)}")
appendLine("新增: ${summary.dataAdded / 1024 / 1024} MB")
appendLine("文件: ${summary.totalFilesProcessed}")
} else {
val err = resticError
if (err != null) {
appendLine()
appendLine("── Restic 错误 ──")
appendLine(err)
}
}
})
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
updateStatus("备份异常: ${e.message}")
} finally {
setRunning(false)
binding.backupButton.isEnabled = true
binding.scanButton.isEnabled = true
// Stop foreground service
try {
val stopIntent = Intent(requireContext(), BackupService::class.java)
stopIntent.action = BackupService.ACTION_STOP_BACKUP
requireContext().startService(stopIntent)
} catch (_: Exception) {}
}
}
}
private fun setRunning(running: Boolean) {
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
}
private suspend fun updateStatus(text: String) {
withContext(Dispatchers.Main) { binding.statusText.text = text }
}
override fun onDestroyView() {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
ResticWrapper.cleanup()
}
}
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,161 @@
package com.example.androidbackupgui.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SortByAlpha
import androidx.compose.material.icons.filled.Storage
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.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.androidbackupgui.backup.AppInfo
/**
* 备份主页——应用选择、扫描和备份执行。
*
* 业务逻辑在 [BackupViewModel] 中UI 只负责渲染和事件转发。
*/
@Composable
fun BackupScreen(viewModel: BackupViewModel = viewModel()) {
val context = LocalContext.current
val state by viewModel.state.collectAsState()
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 = { viewModel.scanApps(context) },
enabled = !state.isScanning && !state.isRunning,
modifier = Modifier.weight(1f),
) {
if (state.isScanning) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
}
Text("扫描应用")
}
}
// Sort/filter row
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip(
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)) },
)
FilterChip(
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)) },
)
Spacer(Modifier.width(8.dp))
TextButton(onClick = { viewModel.selectAll() }) { Text("全选") }
TextButton(onClick = { viewModel.clearSelection() }) { Text("取消全选") }
}
// Show system switch
Row(verticalAlignment = Alignment.CenterVertically) {
Text("显示系统应用", modifier = Modifier.weight(1f))
Switch(checked = state.showSystemApps, onCheckedChange = { viewModel.toggleShowSystem() })
}
}
}
// ── Status ──
Text(
text = state.statusText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
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),
) {
items(state.sortedApps, key = { it.packageName.value }) { app ->
AppListItem(
app = app,
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) {
Button(
onClick = { viewModel.executeBackup(context) },
enabled = !state.isRunning && state.selectedApps.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(12.dp),
) {
if (state.isRunning) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
}
Text("开始备份 (${state.selectedApps.size})")
}
}
}
}
@Composable
private fun AppListItem(
app: AppInfo,
isSelected: Boolean,
isDataExcluded: Boolean,
onToggle: (Boolean) -> Unit,
onExcludeDataToggle: (Boolean) -> Unit,
) {
Card(
onClick = { onToggle(!isSelected) },
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(12.dp),
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,
)
Text(
text = app.packageName.value,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (isSelected) {
TextButton(onClick = { onExcludeDataToggle(!isDataExcluded) }) {
Text(
"数据",
textDecoration = if (isDataExcluded) TextDecoration.LineThrough else TextDecoration.None,
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

@@ -1,259 +0,0 @@
package com.example.androidbackupgui.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import android.util.Log
import com.google.android.material.snackbar.Snackbar
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.example.androidbackupgui.R
import com.example.androidbackupgui.backup.BackupConfig
import com.example.androidbackupgui.databinding.FragmentConfigBinding
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.example.androidbackupgui.backup.ResticWrapper
class ConfigFragment : Fragment() {
companion object { private const val TAG = "ConfigFragment" }
private var _binding: FragmentConfigBinding? = null
private val binding get() = _binding!!
private val vm: ConfigViewModel by viewModels()
private var formLoading = false
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentConfigBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Load config from file into ViewModel state
vm.load()
// Populate form fields from initial state (prevents listener chain)
loadForm()
// ── Change listeners ─────────────────────────────────────────
binding.saveConfigButton.setOnClickListener { saveConfig() }
binding.resticBackendGroup.addOnButtonCheckedListener { _, _, _ ->
onBackendChanged(); refreshResticStatus()
}
binding.resticEnabledSwitch.setOnCheckedChangeListener { _, _ -> refreshResticStatus() }
binding.resticRepoEdit.doAfterTextChanged {
if (formLoading) return@doAfterTextChanged
onFormChanged()
refreshResticStatus()
}
binding.resticBackendUrlEdit.doAfterTextChanged {
if (formLoading) return@doAfterTextChanged
onFormChanged()
}
binding.resticPasswordEdit.doAfterTextChanged {
if (formLoading) return@doAfterTextChanged
refreshResticStatus()
}
binding.initResticButton.setOnClickListener { initResticRepo() }
binding.resticStatsButton.setOnClickListener { showResticStats() }
binding.resticPruneButton.setOnClickListener { pruneResticSnapshots() }
// Initial async status check
refreshResticStatus()
// Observe ViewModel state and one-shot operation events
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
vm.uiState.collect { state -> applyState(state) }
}
launch {
vm.operationEvents.collect { event -> handleOperationEvent(event) }
}
}
}
}
// ── Initial form population ──────────────────────────────────────
/** Populate EditTexts from ViewModel's current config. */
private fun loadForm() {
formLoading = true
val config = vm.uiState.value.config
binding.backupModeSwitch.isChecked = config.backupMode == 1
binding.backupUserDataSwitch.isChecked = config.backupUserData == 1
binding.backupObbSwitch.isChecked = config.backupObbData == 1
binding.backupWifiSwitch.isChecked = config.backupWifi == 1
binding.ignoreRunningSwitch.isChecked = config.backgroundAppsIgnore == 1
binding.outputPathEdit.setText(config.outputPath)
binding.compressionEdit.setText(config.compressionMethod)
binding.resticEnabledSwitch.isChecked = config.resticEnabled == 1
binding.resticRepoEdit.setText(config.resticRepo)
binding.resticPasswordEdit.setText(config.resticPassword)
binding.resticBackendUrlEdit.setText(config.resticBackendUrl)
binding.resticBackendUserEdit.setText(config.resticBackendUser)
binding.resticBackendPassEdit.setText(config.resticBackendPass)
binding.resticBackendShareEdit.setText(config.resticBackendShare)
binding.resticBackendDomainEdit.setText(config.resticBackendDomain)
binding.resticBackendGroup.check(
when (config.resticBackend) {
"webdav" -> R.id.resticBackendWebdav
"smb" -> R.id.resticBackendSmb
"rest-server" -> R.id.resticBackendRestServer
else -> R.id.resticBackendLocal
}
)
formLoading = false
}
// ── StateFlow observer ───────────────────────────────────────────
/** Apply ViewModel state to non-form views (visibility, text, enabled). */
private fun applyState(state: ConfigUiState) {
with(state.backendDisplay) {
binding.resticBackendUrlLayout.visibility = if (isRemote) View.VISIBLE else View.GONE
binding.resticBackendShareLayout.visibility = if (isSmb) View.VISIBLE else View.GONE
binding.resticBackendDomainLayout.visibility = if (isSmb) View.VISIBLE else View.GONE
binding.resticBackendUserLayout.visibility = if (needsAuth) View.VISIBLE else View.GONE
binding.resticBackendPassLayout.visibility = if (needsAuth) View.VISIBLE else View.GONE
binding.resticBackendUrlLayout.hint = urlHint
binding.resticComputedUrlText.text = if (state.config.resticRepo.isNotEmpty())
"实际仓库: $computedUrl" else ""
}
with(state.resticStatus) {
binding.resticStatusText.text = message
binding.initResticButton.isEnabled = initButtonEnabled
binding.initResticButton.visibility = if (initButtonVisible) View.VISIBLE else View.GONE
binding.resticStatsButton.isEnabled = statsButtonEnabled
binding.resticStatsButton.visibility = if (statsButtonVisible) View.VISIBLE else View.GONE
binding.resticPruneButton.isEnabled = pruneButtonEnabled
binding.resticPruneButton.visibility = if (pruneButtonVisible) View.VISIBLE else View.GONE
}
}
// ── One-shot operation event handler ──────────────────────────────
/** Handle one-shot lifecycle events from ViewModel. */
private fun handleOperationEvent(event: OperationEvent) {
when (event) {
is OperationEvent.InitStarted -> Log.d(TAG, "init started")
is OperationEvent.InitCompleted -> {
Log.d(TAG, "init completed")
Snackbar.make(binding.root, "仓库初始化完成", Snackbar.LENGTH_SHORT).show()
}
is OperationEvent.InitFailed -> {
Log.d(TAG, "init failed")
Snackbar.make(binding.root, "仓库初始化失败", Snackbar.LENGTH_SHORT).show()
}
is OperationEvent.StatsStarted -> Log.d(TAG, "stats started")
is OperationEvent.StatsCompleted -> {
Log.d(TAG, "stats completed")
Snackbar.make(binding.root, "统计读取完成", Snackbar.LENGTH_SHORT).show()
}
is OperationEvent.PruneStarted -> Log.d(TAG, "prune started")
is OperationEvent.PruneFailed -> {
Log.d(TAG, "prune failed")
Snackbar.make(binding.root, "清理失败", Snackbar.LENGTH_SHORT).show()
}
is OperationEvent.PruneCompleted -> {
Log.d(TAG, "prune completed")
Snackbar.make(binding.root, "清理完成", Snackbar.LENGTH_SHORT).show()
}
}
}
// ── Form building helpers ────────────────────────────────────────
private fun readBackend(): String = when (binding.resticBackendGroup.checkedButtonId) {
R.id.resticBackendWebdav -> "webdav"
R.id.resticBackendSmb -> "smb"
R.id.resticBackendRestServer -> "rest-server"
else -> "local"
}
private fun readResticForm() = ResticForm(
repo = binding.resticRepoEdit.text?.toString()?.trim() ?: "",
password = binding.resticPasswordEdit.text?.toString() ?: "",
backend = readBackend(),
backendUrl = binding.resticBackendUrlEdit.text?.toString()?.trim() ?: "",
backendUser = binding.resticBackendUserEdit.text?.toString()?.trim() ?: "",
backendPass = binding.resticBackendPassEdit.text?.toString() ?: "",
backendShare = binding.resticBackendShareEdit.text?.toString()?.trim() ?: "",
backendDomain = binding.resticBackendDomainEdit.text?.toString()?.trim() ?: ""
)
// ── User actions ─────────────────────────────────────────────────
private fun saveConfig() {
vm.save(BackupConfig(
backupMode = if (binding.backupModeSwitch.isChecked) 1 else 0,
backupUserData = if (binding.backupUserDataSwitch.isChecked) 1 else 0,
backupObbData = if (binding.backupObbSwitch.isChecked) 1 else 0,
backupWifi = if (binding.backupWifiSwitch.isChecked) 1 else 0,
backgroundAppsIgnore = if (binding.ignoreRunningSwitch.isChecked) 1 else 0,
outputPath = binding.outputPathEdit.text?.toString() ?: "",
compressionMethod = binding.compressionEdit.text?.toString()?.ifEmpty { "zstd" } ?: "zstd",
resticEnabled = if (binding.resticEnabledSwitch.isChecked) 1 else 0,
resticRepo = binding.resticRepoEdit.text?.toString()?.trim() ?: "",
resticPassword = binding.resticPasswordEdit.text?.toString() ?: "",
resticBackend = readBackend(),
resticBackendUrl = binding.resticBackendUrlEdit.text?.toString()?.trim() ?: "",
resticBackendUser = binding.resticBackendUserEdit.text?.toString()?.trim() ?: "",
resticBackendPass = binding.resticBackendPassEdit.text?.toString() ?: "",
resticBackendShare = binding.resticBackendShareEdit.text?.toString()?.trim() ?: "",
resticBackendDomain = binding.resticBackendDomainEdit.text?.toString()?.trim() ?: "",
))
}
private fun onFormChanged() {
val backend = readBackend()
val repo = binding.resticRepoEdit.text?.toString()?.trim() ?: ""
val url = binding.resticBackendUrlEdit.text?.toString()?.trim() ?: ""
vm.onFormChanged(backend, repo, url)
}
private fun onBackendChanged() {
val backend = readBackend()
val repo = binding.resticRepoEdit.text?.toString()?.trim() ?: ""
val url = binding.resticBackendUrlEdit.text?.toString()?.trim() ?: ""
vm.onFormChanged(backend, repo, url)
}
private fun refreshResticStatus() {
vm.refreshResticStatus(readResticForm())
}
private fun initResticRepo() {
vm.initResticRepo(readResticForm())
}
private fun showResticStats() {
vm.showResticStats(readResticForm())
}
private fun pruneResticSnapshots() {
vm.pruneResticSnapshots(readResticForm())
}
override fun onDestroyView() {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
ResticWrapper.cleanup()
}
}
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,615 @@
package com.example.androidbackupgui.ui
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.androidbackupgui.backup.AppScanner
import com.example.androidbackupgui.backup.BackupConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConfigScreen(
viewModel: ConfigViewModel = viewModel(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val config = uiState.config
val backendDisplay = uiState.backendDisplay
val status = uiState.resticStatus
// ── Local editing state (initialized from ViewModel on first load) ──
var backupMode by remember { mutableStateOf(config.backupMode == 1) }
var backupUserData by remember { mutableStateOf(config.backupUserData == 1) }
var backupObb by remember { mutableStateOf(config.backupObbData == 1) }
var backupWifi by remember { mutableStateOf(config.backupWifi == 1) }
var ignoreRunning by remember { mutableStateOf(config.backgroundAppsIgnore == 1) }
var outputPath by remember { mutableStateOf(config.outputPath) }
var compressionMethod by remember { mutableStateOf(config.compressionMethod) }
var backupUserId by remember { mutableIntStateOf(config.backupUserId) }
var userList by remember { mutableStateOf<List<Pair<Int, String>>>(listOf(0 to "Owner")) }
var resticEnabled by remember { mutableStateOf(config.resticEnabled == 1) }
var resticRepo by remember { mutableStateOf(config.resticRepo) }
var resticPassword by remember { mutableStateOf(config.resticPassword) }
var resticBackend by remember { mutableStateOf(config.resticBackend) }
var resticBackendUrl by remember { mutableStateOf(config.resticBackendUrl) }
var resticBackendUser by remember { mutableStateOf(config.resticBackendUser) }
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) {
backupMode = config.backupMode == 1
backupUserData = config.backupUserData == 1
backupObb = config.backupObbData == 1
backupWifi = config.backupWifi == 1
ignoreRunning = config.backgroundAppsIgnore == 1
outputPath = config.outputPath
compressionMethod = config.compressionMethod
backupUserId = config.backupUserId
resticEnabled = config.resticEnabled == 1
resticRepo = config.resticRepo
resticPassword = config.resticPassword
resticBackend = config.resticBackend
resticBackendUrl = config.resticBackendUrl
resticBackendUser = config.resticBackendUser
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()
}
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 -> "配置导出失败"
is OperationEvent.ConfigImported -> "配置已导入"
is OperationEvent.ConfigImportFailed -> "配置导入失败"
else -> null
}
if (msg != null) {
snackbarHostState.showSnackbar(msg)
}
}
}
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)
}
// 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),
) {
// ── Backup settings section ──
Text("备份设置", style = MaterialTheme.typography.titleMedium)
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("备份模式", modifier = Modifier.weight(1f))
Switch(checked = backupMode, onCheckedChange = { backupMode = it })
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("备份用户数据", modifier = Modifier.weight(1f))
Switch(checked = backupUserData, onCheckedChange = { backupUserData = it })
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("备份 OBB 数据", modifier = Modifier.weight(1f))
Switch(checked = backupObb, onCheckedChange = { backupObb = it })
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("备份 WiFi 配置", modifier = Modifier.weight(1f))
Switch(checked = backupWifi, onCheckedChange = { backupWifi = it })
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("忽略运行中的应用", modifier = Modifier.weight(1f))
Switch(checked = ignoreRunning, onCheckedChange = { ignoreRunning = it })
}
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,
)
// Backup user selector
UserSelector(
userList = userList,
selectedUserId = backupUserId,
onUserSelected = { backupUserId = it },
)
}
}
// ── Restic section ──
HorizontalDivider()
Text("Restic 备份", style = MaterialTheme.typography.titleMedium)
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("启用 Restic", modifier = Modifier.weight(1f))
Switch(checked = resticEnabled, onCheckedChange = { resticEnabled = it })
}
if (resticEnabled) {
OutlinedTextField(
value = resticRepo,
onValueChange = {
resticRepo = it
viewModel.onFormChanged(resticBackend, it, resticBackendUrl)
},
label = { Text("仓库路径") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = resticPassword,
onValueChange = { resticPassword = it },
label = { Text("仓库密码") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation =
androidx.compose.ui.text.input
.PasswordVisualTransformation(),
)
// Backend selection radio group
Text("后端类型", style = MaterialTheme.typography.labelLarge)
val backends = listOf("local" to "本地", "webdav" to "WebDAV", "smb" to "SMB", "rest-server" to "rest-server")
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,
) {
RadioButton(
selected = resticBackend == value,
onClick = null,
)
Spacer(Modifier.width(8.dp))
Text(label)
}
}
}
// Computed URL
if (resticRepo.isNotEmpty()) {
Text(
text = "实际仓库: ${backendDisplay.computedUrl}",
style = MaterialTheme.typography.bodySmall,
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,
)
}
// ── 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(),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = status.message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
if (status.initButtonVisible) {
Button(
onClick = {
viewModel.initResticRepo(
buildResticForm(
resticRepo,
resticPassword,
resticBackend,
resticBackendUrl,
resticBackendUser,
resticBackendPass,
resticBackendShare,
resticBackendDomain,
),
)
},
enabled = status.initButtonEnabled,
modifier = Modifier.fillMaxWidth(),
) {
Text("初始化仓库")
}
}
if (status.statsButtonVisible) {
Button(
onClick = {
viewModel.showResticStats(
buildResticForm(
resticRepo,
resticPassword,
resticBackend,
resticBackendUrl,
resticBackendUser,
resticBackendPass,
resticBackendShare,
resticBackendDomain,
),
)
},
enabled = status.statsButtonEnabled,
modifier = Modifier.fillMaxWidth(),
) {
Text("仓库统计")
}
}
if (status.pruneButtonVisible) {
OutlinedButton(
onClick = {
viewModel.pruneResticSnapshots(
buildResticForm(
resticRepo,
resticPassword,
resticBackend,
resticBackendUrl,
resticBackendUser,
resticBackendPass,
resticBackendShare,
resticBackendDomain,
),
)
},
enabled = status.pruneButtonEnabled,
modifier = Modifier.fillMaxWidth(),
) {
Text("清理旧快照")
}
}
if (status.unlockButtonVisible) {
Button(
onClick = {
viewModel.unlockResticRepo(
buildResticForm(
resticRepo,
resticPassword,
resticBackend,
resticBackendUrl,
resticBackendUser,
resticBackendPass,
resticBackendShare,
resticBackendDomain,
),
)
},
enabled = status.unlockButtonEnabled,
modifier = Modifier.fillMaxWidth(),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.tertiary,
),
) {
Text("解锁仓库")
}
}
}
}
}
}
}
Spacer(Modifier.height(8.dp))
// ── Save button ──
Button(
onClick = {
viewModel.save(
BackupConfig(
backupMode = if (backupMode) 1 else 0,
backupUserData = if (backupUserData) 1 else 0,
backupObbData = if (backupObb) 1 else 0,
backupWifi = if (backupWifi) 1 else 0,
backgroundAppsIgnore = if (ignoreRunning) 1 else 0,
backupUserId = backupUserId,
outputPath = outputPath,
compressionMethod = compressionMethod.ifEmpty { "zstd" },
resticEnabled = if (resticEnabled) 1 else 0,
resticRepo = resticRepo,
resticPassword = resticPassword,
resticBackend = resticBackend,
resticBackendUrl = resticBackendUrl,
resticBackendUser = resticBackendUser,
resticBackendPass = resticBackendPass,
resticBackendShare = resticBackendShare,
resticBackendDomain = resticBackendDomain,
useStreaming = if (streamingEnabled) 1 else 0,
),
)
},
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Save, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("保存配置")
}
// ── Import / Export config buttons ──
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
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,
)
}
Spacer(Modifier.height(32.dp))
}
}
// ── User selector ──
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun UserSelector(
userList: List<Pair<Int, String>>,
selectedUserId: Int,
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)"
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = selectedName,
onValueChange = {},
readOnly = true,
label = { Text("备份用户") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
singleLine = true,
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
userList.forEach { (id, name) ->
DropdownMenuItem(
text = { Text("$name (ID: $id)") },
onClick = {
onUserSelected(id)
expanded = false
},
)
}
}
}
}
/** 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,
) = ResticForm(
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,32 +1,34 @@
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.RemoteTransport
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.Job
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.runBlocking
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
/** UI-visible state driven by [ConfigViewModel]. */
data class ConfigUiState(
val config: BackupConfig = BackupConfig(),
val backendDisplay: BackendDisplay = BackendDisplay(),
val resticStatus: ResticStatus = ResticStatus()
val resticStatus: ResticStatus = ResticStatus(),
)
data class BackendDisplay(
@@ -34,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(
@@ -45,15 +47,21 @@ data class ResticStatus(
val statsButtonVisible: Boolean = false,
val statsButtonEnabled: Boolean = true,
val pruneButtonVisible: Boolean = false,
val pruneButtonEnabled: Boolean = true
val pruneButtonEnabled: Boolean = true,
val unlockButtonVisible: Boolean = false,
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,
)
/**
@@ -62,38 +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 {
@@ -107,6 +138,16 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
private val _uiState = MutableStateFlow(ConfigUiState())
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow()
/** Guards against concurrent [initResticRepo] calls. */
private val initGuard = AtomicBoolean(false)
/** Guards against stale [refreshResticStatus] coroutines. */
private var refreshJob: Job? = null
init {
load()
}
/** Read config from file and refresh restic status. */
fun load() {
val config = BackupConfig.fromFile(configFile)
@@ -117,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) }
}
@@ -136,14 +199,151 @@ 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(resticStatus = it.resticStatus.copy(message = "配置已保存到 $configFile"))
it.copy(
config = formConfig,
backendDisplay =
deriveBackendDisplay(
formConfig.resticBackend,
formConfig.resticRepo,
formConfig.resticBackendUrl,
),
resticStatus = it.resticStatus.copy(message = "配置已保存到 $configFile"),
)
}
refreshResticStatus(readResticForm())
}
}
/**
* Export the current saved config to a user-selected destination [Uri] (SAF).
* Writes the same on-disk config format, including the plaintext restic password,
* so the warning is surfaced in the UI before export.
*/
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() }
}
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 = "配置已导出(密码未包含,需在目标设备上通过应用重新输入)",
),
)
}
} else {
_operationEvents.emit(OperationEvent.ConfigExportFailed)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "配置导出失败")) }
}
}
}
/**
* 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 = "配置导入失败")) }
}
}
}
@@ -153,23 +353,32 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
val ctx = getApplication<Application>()
val binaryPath = ResticBinary.prepare(ctx)
if (binaryPath == null) return false
ResticWrapper.binaryPath = binaryPath
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(ctx)
defaultResticWrapper.binaryPath = binaryPath
defaultResticWrapper.cacheDir = ctx.cacheDir.absolutePath
return true
}
// ── Async restic operations ──────────────────────────────────────
fun initResticRepo(form: ResticForm) {
if (!initGuard.compareAndSet(false, true)) {
Log.w(TAG, "initResticRepo: already in progress, ignoring")
return
}
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()) {
@@ -177,118 +386,284 @@ 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,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
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}", initButtonEnabled = true
))}
_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}", initButtonEnabled = true
))}
_uiState.update {
it.copy(
resticStatus =
it.resticStatus.copy(
message = "初始化失败: ${result.exceptionOrNull()?.message}",
),
)
}
refreshResticStatus(form)
}
} finally {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(initButtonEnabled = true)) }
initGuard.set(false)
}
}
}
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 = "正在检测仓库状态…")) }
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,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
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
))}
} else {
_uiState.update { it.copy(resticStatus = ResticStatus(
message = "仓库未初始化或认证失败",
initButtonVisible = true, statsButtonVisible = false, pruneButtonVisible = false
))}
// Cancel any stale status check so a slow old coroutine doesn't overwrite new results
refreshJob?.cancel()
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 (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 =
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 {
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,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
val snapshotsResult = ResticWrapper.listSnapshots(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
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)) }
@@ -297,47 +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)
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,
// Remove stale locks before forget/prune
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,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
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,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
_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 {
@@ -348,30 +761,4 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
}
}
}
// ── Internal progress helpers ─────────────────────────────────────
private fun onSyncProgress(p: RemoteTransport.TransferProgress) {
_uiState.update {
it.copy(resticStatus = it.resticStatus.copy(
message = "同步中: ${p.current}/${p.total} 个文件"
))
}
}
private fun onByteProgress(p: RemoteTransport.ByteProgress) {
_uiState.update {
it.copy(resticStatus = it.resticStatus.copy(
message = "同步中: ${p.currentFile}\n${formatSize(p.bytesTransferred)} / ${formatSize(p.totalBytes)}"
))
}
}
/** Cleanup ResticWrapper resources when ViewModel is cleared. */
override fun onCleared() {
super.onCleared()
runBlocking(Dispatchers.IO) {
ResticWrapper.cleanup()
}
}
}

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,77 +0,0 @@
package com.example.androidbackupgui.ui
import android.view.View
import android.util.TypedValue
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.example.androidbackupgui.R
import com.example.androidbackupgui.backup.AppInfo
import com.google.android.material.card.MaterialCardView
import com.google.android.material.color.MaterialColors
/**
* RecyclerView adapter showing app names (or package names as fallback) with checkboxes.
* Used by both BackupFragment and RestoreFragment.
*/
class PackageListAdapter(
private val apps: List<AppInfo>,
private val selected: Set<String>,
private val onToggle: (String, Boolean) -> Unit
) : RecyclerView.Adapter<PackageListAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val checkbox: CheckBox = view.findViewById(R.id.checkbox)
val textView: TextView = view.findViewById(R.id.appName)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val ctx = parent.context
val res = ctx.resources
val card = MaterialCardView(ctx).apply {
layoutParams = ViewGroup.MarginLayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply { setMargins(0, 0, 0, res.getDimensionPixelSize(R.dimen.card_margin_bottom)) }
radius = res.getDimension(R.dimen.card_radius)
cardElevation = 0f
strokeWidth = 0
setCardBackgroundColor(
MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurfaceContainer, 0)
)
}
val layout = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(res.getDimensionPixelSize(R.dimen.card_padding_horizontal), res.getDimensionPixelSize(R.dimen.card_padding_vertical), res.getDimensionPixelSize(R.dimen.card_padding_horizontal), res.getDimensionPixelSize(R.dimen.card_padding_vertical))
}
val cb = CheckBox(ctx).apply { id = R.id.checkbox }
val tv = TextView(ctx).apply {
id = R.id.appName
setPadding(res.getDimensionPixelSize(R.dimen.card_padding_horizontal), 0, 0, 0)
setTextSize(TypedValue.COMPLEX_UNIT_PX, res.getDimension(R.dimen.list_item_text_size))
setTextColor(
MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, 0)
)
}
layout.addView(cb)
layout.addView(tv)
card.addView(layout)
return ViewHolder(card)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val app = apps[position]
// Prefer app name (label), fall back to package name
holder.textView.text = app.label.ifEmpty { app.packageName.value }
// Avoid re-triggering listener during bind
holder.checkbox.setOnCheckedChangeListener(null)
holder.checkbox.isChecked = app.packageName.value in selected
holder.checkbox.setOnCheckedChangeListener { _, checked ->
onToggle(app.packageName.value, checked)
}
}
override fun getItemCount() = apps.size
}

View File

@@ -1,425 +0,0 @@
package com.example.androidbackupgui.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment
import android.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.androidbackupgui.backup.AppInfo
import com.example.androidbackupgui.backup.PackageName
import com.example.androidbackupgui.backup.AppScanner
import com.example.androidbackupgui.backup.BackupConfig
import com.example.androidbackupgui.backup.RestoreOperation
import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.ResticWrapper
import com.example.androidbackupgui.backup.WifiManager
import com.example.androidbackupgui.backup.RemoteTransport
import com.example.androidbackupgui.databinding.FragmentRestoreBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import com.example.androidbackupgui.backup.formatSize
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
class RestoreFragment : Fragment() {
private var _binding: FragmentRestoreBinding? = null
private val binding get() = _binding!!
private var backupDir: File? = null
private var packages: List<String> = emptyList()
private var appInfos: List<AppInfo> = emptyList()
private var selectedPackages = mutableSetOf<String>()
private var resticConfig: BackupConfig? = null
private var selectedSnapshot: ResticWrapper.ResticSnapshot? = null
private var resticConfigFingerprint: String? = null
private var selectedUserId: Int = 0
private var userList: List<Pair<Int, String>> = listOf(0 to "Owner")
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentRestoreBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.appList.layoutManager = LinearLayoutManager(requireContext())
// Load restic config
val configFile = File(requireContext().filesDir, "backup_settings.conf")
val config = BackupConfig.fromFile(configFile)
// Show restic button if enabled and binary available
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
resticConfig = config
val binaryPath = ResticBinary.prepare(requireContext())
if (binaryPath != null) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
ResticWrapper.backendDomain = config.resticBackendDomain
binding.selectResticButton.visibility = View.VISIBLE
}
}
binding.selectDirButton.setOnClickListener { selectBackupDir() }
binding.selectResticButton.setOnClickListener { selectResticSnapshot() }
binding.restoreButton.setOnClickListener { startRestore() }
// Load user profiles
loadUsers()
}
private fun loadUsers() {
viewLifecycleOwner.lifecycleScope.launch {
try {
userList = AppScanner.enumerateUsers()
val names = userList.map { (id, name) -> "$name (ID: $id)" }
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.userSelector.adapter = adapter
binding.userSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedUserId = userList.getOrNull(position)?.first ?: 0
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
} catch (e: Exception) {
binding.statusText.text = "加载用户失败: ${e.message}"
}
}
}
override fun onResume() {
super.onResume()
// Re-read config so changes from ConfigFragment take effect immediately
val configFile = File(requireContext().filesDir, "backup_settings.conf")
val config = BackupConfig.fromFile(configFile)
// Detect restic config change — clear stale state if repo/backend changed
val newFingerprint = "${config.resticRepo}|${config.resticBackend}|${config.resticBackendUrl}"
if (resticConfigFingerprint != null && resticConfigFingerprint != newFingerprint) {
selectedSnapshot = null
packages = emptyList()
selectedPackages.clear()
binding.backupDirText.text = ""
binding.restoreButton.isEnabled = false
binding.selectResticButton.visibility = View.GONE
}
resticConfigFingerprint = newFingerprint
resticConfig = if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) config else null
// Skip redundant preparation if binary and backend config are already set
if (resticConfig != null &&
ResticWrapper.binaryPath.isNotEmpty() &&
ResticWrapper.binaryPath != "restic" &&
ResticWrapper.backendDomain == config.resticBackendDomain
) {
binding.selectResticButton.visibility = View.VISIBLE
} else {
val binaryPath = ResticBinary.prepare(requireContext())
if (binaryPath != null && resticConfig != null) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
ResticWrapper.backendDomain = config.resticBackendDomain
binding.selectResticButton.visibility = View.VISIBLE
}
}
}
private fun selectBackupDir() {
val defaultDir = File(requireContext().filesDir.absolutePath)
val backupDirs = defaultDir.listFiles()
?.filter { it.isDirectory && it.name.startsWith("Backup_") }
?: emptyList()
if (backupDirs.isNotEmpty()) {
backupDir = backupDirs.first()
selectedSnapshot = null
loadBackupDir(backupDirs.first())
} else {
binding.statusText.text = "未找到备份目录,请确保 Backup_* 文件夹存在于 ${defaultDir.absolutePath}"
}
}
private fun loadBackupDir(dir: File) {
binding.backupDirText.text = dir.absolutePath
val appListFile = File(dir, "appList.txt")
packages = if (appListFile.exists()) {
appListFile.readLines()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
} else {
dir.listFiles()
?.filter { it.isDirectory }
?.map { it.name }
?: emptyList()
}
selectedPackages.clear()
selectedPackages.addAll(packages)
binding.statusText.text = "${packages.size} 个备份应用"
binding.restoreButton.isEnabled = packages.isNotEmpty()
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = PackageName(it)) })
setupAppList()
}
private fun selectResticSnapshot() {
val config = resticConfig ?: return
setRunning(true)
binding.statusText.text = "正在同步远程仓库到本地…"
viewLifecycleOwner.lifecycleScope.launch {
try {
val snapshotsResult = ResticWrapper.listSnapshots(
config.resticRepo, config.resticPassword,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onSyncProgress = { p ->
updateStatus("同步中: ${p.current}/${p.total} [${p.currentFile}]")
},
onByteSyncProgress = { bp ->
updateStatus("下载中: ${bp.bytesTransferred / 1024 / 1024} MB / ${bp.totalBytes / 1024 / 1024} MB")
}
)
if (snapshotsResult.isFailure) {
updateStatus("读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}")
setRunning(false)
return@launch
}
val snapshots = snapshotsResult.getOrThrow()
if (snapshots.isEmpty()) {
updateStatus("没有可用的 restic 快照")
setRunning(false)
return@launch
}
// 多快照时让用户选择,单个快照自动选
val chosenSnapshot = if (snapshots.size == 1) {
snapshots.first()
} else {
pickSnapshot(snapshots) ?: run {
updateStatus("已取消选择")
setRunning(false)
return@launch
}
}
// Switch to restic source
backupDir = null
selectedSnapshot = chosenSnapshot
val backupPath = selectedSnapshot!!.paths.firstOrNull() ?: run {
updateStatus("快照中找不到备份路径")
setRunning(false)
return@launch
}
// Read app list from the snapshot
val appListContent = readResticFile(config, selectedSnapshot!!.id, "$backupPath/appList.txt")
packages = if (appListContent != null) {
appListContent.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
} else {
emptyList()
}
if (packages.isEmpty()) {
updateStatus("无法从快照读取应用列表")
setRunning(false)
return@launch
}
binding.backupDirText.text = "restic: ${selectedSnapshot!!.time.take(19)} (${snapshots.size} 个快照可用)"
selectedPackages.clear()
selectedPackages.addAll(packages)
// Resolve app labels for display
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = PackageName(it)) })
updateStatus("restic 快照共 ${packages.size} 个应用,点击恢复开始")
binding.restoreButton.isEnabled = true
setRunning(false)
setupAppList()
} catch (e: Exception) {
binding.statusText.text = "选择快照失败: ${e.message}"
setRunning(false)
}
}
}
/** 多快照时弹出选择对话框。返回用户选择的快照,取消时返回 null。 */
private suspend fun pickSnapshot(snapshots: List<ResticWrapper.ResticSnapshot>): ResticWrapper.ResticSnapshot? =
suspendCancellableCoroutine { cont ->
val names = snapshots.map { "${it.time.take(19)} (${it.id.take(8)})" }
AlertDialog.Builder(requireContext())
.setTitle("选择快照")
.setItems(names.toTypedArray()) { _, i -> cont.resume(snapshots[i]) }
.setOnCancelListener { cont.resume(null) }
.show()
}
/** Read a single file from a restic snapshot using `restic dump`. */
private suspend fun readResticFile(
config: BackupConfig,
snapshotId: String,
filePath: String
): String? {
val result = ResticWrapper.dump(
config.resticRepo, config.resticPassword,
snapshotId, filePath,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare
)
return result.getOrNull()
}
private fun setupAppList() {
binding.appList.adapter = PackageListAdapter(appInfos, selectedPackages) { pkg, checked ->
if (checked) selectedPackages.add(pkg) else selectedPackages.remove(pkg)
binding.statusText.text = "已选择 ${selectedPackages.size}/${packages.size} 个应用"
}
}
private fun startRestore() {
val toRestore = packages.filter { it in selectedPackages }
if (toRestore.isEmpty()) return
setRunning(true)
binding.restoreButton.isEnabled = false
binding.selectDirButton.isEnabled = false
viewLifecycleOwner.lifecycleScope.launch {
try {
val result = if (selectedSnapshot != null && resticConfig != null) {
// Restic restore
val snapshot = selectedSnapshot ?: return@launch
val config = resticConfig ?: return@launch
val backupPath = snapshot.paths.firstOrNull() ?: return@launch
val staging = File(requireContext().cacheDir, "restic_restore_${snapshot.shortId}")
staging.mkdirs()
try {
binding.progressBar.isIndeterminate = true
binding.statusText.text = "正在从 restic 快照恢复到暂存目录…"
val restoreResult = 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,
onSyncProgress = { progress: RemoteTransport.TransferProgress ->
if (progress.phase in listOf("list", "download", "upload", "delete_stale")) {
updateStatus("同步中: ${progress.current}/${progress.total} 个文件")
}
},
onByteSyncProgress = { progress ->
withContext(Dispatchers.Main) {
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
binding.progressBar.progress = progress.bytesTransferred.toInt()
}
updateStatus("同步中: ${progress.currentFile}\n" +
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}")
},
onProgress = { msg -> withContext(Dispatchers.Main) { binding.statusText.text = msg } }
)
if (restoreResult.isFailure) {
updateStatus("restic 恢复失败: ${restoreResult.exceptionOrNull()?.message}")
return@launch
}
// The restored backup directory: <staging>/<original_absolute_path>
val restoredBackupDir = File(staging, backupPath.removePrefix("/"))
updateStatus("正在从恢复的备份安装应用…")
val r = RestoreOperation.restoreApps(
context = requireContext(),
backupDir = restoredBackupDir,
userId = selectedUserId.toString(),
filterPkgs = selectedPackages,
onProgress = { progress ->
val label = appInfos.find { it.packageName.value == progress.packageName }?.label
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
binding.statusText.text =
"[${progress.current}/${progress.total}] $name: ${progress.message}"
}
)
// Also restore WiFi if backup exists
WifiManager.restore(restoredBackupDir)
r
} finally {
try { staging.deleteRecursively() } catch (_: Exception) {}
}
} else {
// Local restore
val dir = backupDir ?: return@launch
val r = RestoreOperation.restoreApps(
context = requireContext(),
backupDir = dir,
userId = selectedUserId.toString(),
filterPkgs = selectedPackages,
onProgress = { progress ->
val label = appInfos.find { it.packageName.value == progress.packageName }?.label
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
binding.statusText.text =
"[${progress.current}/${progress.total}] $name: ${progress.message}"
}
)
// Also restore WiFi if backup exists locally
WifiManager.restore(dir)
r
}
binding.statusText.text = buildString {
appendLine("恢复完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
appendLine("耗时: ${result.elapsedMs / 1000}")
appendLine("如有 SSAID请立即重启设备后再开启应用")
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
binding.statusText.text = "恢复异常: ${e.message}"
} finally {
setRunning(false)
binding.selectDirButton.isEnabled = true
}
}
}
private fun setRunning(running: Boolean) {
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
}
private suspend fun updateStatus(text: String) {
binding.statusText.text = text
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,592 @@
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
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.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
import java.io.File
@Composable
fun RestoreScreen() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// ── State ──
var backupDir by remember { mutableStateOf<File?>(null) }
var packages by remember { mutableStateOf<List<String>>(emptyList()) }
var appInfos by remember { mutableStateOf<List<AppInfo>>(emptyList()) }
var selectedPackages by remember { mutableStateOf<Set<String>>(emptySet()) }
var resticConfig by remember { mutableStateOf<BackupConfig?>(null) }
var config by remember { mutableStateOf(BackupConfig()) }
var selectedSnapshot by remember { mutableStateOf<ResticWrapper.ResticSnapshot?>(null) }
var isRunning by remember { mutableStateOf(false) }
var statusText by remember { mutableStateOf("请选择备份源") }
var showSnapshotPicker by remember { mutableStateOf(false) }
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)
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
resticConfig = config
}
}
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)) {
// Source buttons row
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = {
scope.launch {
try {
val defaultDir = context.filesDir
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
selectedPackages = pkgs.toSet()
statusText = status
}
} else {
statusText = "未找到备份目录"
}
} catch (e: Exception) {
statusText = "选择目录失败: ${e.message}"
}
}
},
enabled = !isRunning,
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
}
scope.launch {
isRunning = true
statusText = "正在读取快照…"
try {
// 配置 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
}
val snaps = result.getOrThrow()
if (snaps.isEmpty()) {
statusText = "没有可用的 restic 快照"
return@launch
}
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
}
} else {
showSnapshotPicker = true
}
} catch (e: Exception) {
statusText = "选择快照失败: ${e.message}"
} finally {
isRunning = false
}
}
},
enabled = !isRunning && resticConfig != null,
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 {
""
}
if (sourceText.isNotEmpty()) {
Text(
text = sourceText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// ── Status ──
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
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),
) {
items(appInfos, key = { it.packageName.value }) { app ->
Card(
onClick = {
val pkg = app.packageName.value
selectedPackages =
if (pkg in selectedPackages) {
selectedPackages - pkg
} else {
selectedPackages + pkg
}
},
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(12.dp),
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
}
},
)
Spacer(Modifier.width(8.dp))
Column {
Text(
text = app.label.ifEmpty { app.packageName.value },
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = app.packageName.value,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
// ── Bottom bar ──
Surface(modifier = Modifier.fillMaxWidth(), tonalElevation = 3.dp) {
Button(
onClick = {
val toRestore = packages.filter { it in selectedPackages }
if (toRestore.isEmpty()) return@Button
isRunning = true
statusText = "开始恢复 ${toRestore.size} 个应用…"
scope.launch {
try {
if (selectedSnapshot != null && resticConfig != null) {
val snapshot = selectedSnapshot!!
val config = resticConfig!!
val backupPath = snapshot.paths.firstOrNull() ?: return@launch
val staging = File(context.cacheDir, "restic_restore_${snapshot.shortId}")
staging.mkdirs()
try {
statusText = "正在从 restic 快照恢复…"
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
}
val restoredDir = File(staging, backupPath.removePrefix("/"))
statusText = "正在从恢复的备份安装应用…"
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 = 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}"
} finally {
isRunning = false
}
}
},
enabled = !isRunning && selectedPackages.isNotEmpty() && (backupDir != null || selectedSnapshot != null),
modifier = Modifier.fillMaxWidth().padding(12.dp),
) {
if (isRunning) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
}
Text("开始恢复 (${selectedPackages.size})")
}
}
}
// ── Snapshot picker dialog ──
if (showSnapshotPicker && availableSnapshots.isNotEmpty()) {
AlertDialog(
onDismissRequest = { showSnapshotPicker = false },
title = { Text("选择快照") },
text = {
Column {
availableSnapshots.forEach { snap ->
val label = "${snap.time.take(19)} (${snap.shortId})"
TextButton(
onClick = {
showSnapshotPicker = false
scope.launch {
loadResticSnapshot(context, snap, resticConfig!!) { pkgs, infos, status ->
backupDir = null
selectedSnapshot = snap
packages = pkgs
appInfos = infos
selectedPackages = pkgs.toSet()
statusText = status
}
}
},
modifier = Modifier.fillMaxWidth(),
) { Text(label) }
}
}
},
confirmButton = {
TextButton(onClick = { showSnapshotPicker = false }) { Text("取消") }
},
)
}
}
private suspend fun loadFromDir(
context: android.content.Context,
dir: File,
onResult: (packages: List<String>, appInfos: List<AppInfo>, status: String) -> Unit,
) {
withContext(Dispatchers.IO) {
val appListFile = File(dir, "appList.txt")
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 =
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
}
}
val suffix = if (skipped > 0) "${skipped}个应用备份数据缺失已自动跳过)" else ""
onResult(validPkgs, infos, "${validPkgs.size} 个备份应用$suffix")
}
}
private suspend fun loadResticSnapshot(
context: android.content.Context,
snapshot: ResticWrapper.ResticSnapshot,
config: BackupConfig,
onResult: (packages: List<String>, appInfos: List<AppInfo>, status: String) -> Unit,
) {
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("#") }
// 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 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
}
}
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")
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,
): Map<String, String> {
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 {
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,69 @@
package com.example.androidbackupgui.ui.theme
import androidx.compose.ui.graphics.Color
// Material 3 light scheme colors
val md_theme_light_primary = Color(0xFF1A6B52)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFA4F2D3)
val md_theme_light_onPrimaryContainer = Color(0xFF002117)
val md_theme_light_secondary = Color(0xFF4C6359)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFCEE9DB)
val md_theme_light_onSecondaryContainer = Color(0xFF092017)
val md_theme_light_tertiary = Color(0xFF3F6373)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFC3E8FB)
val md_theme_light_onTertiaryContainer = Color(0xFF001F29)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFBFDF9)
val md_theme_light_onBackground = Color(0xFF191C1A)
val md_theme_light_surface = Color(0xFFFBFDF9)
val md_theme_light_onSurface = Color(0xFF191C1A)
val md_theme_light_surfaceVariant = Color(0xFFDBE5DD)
val md_theme_light_onSurfaceVariant = Color(0xFF404943)
val md_theme_light_outline = Color(0xFF707973)
val md_theme_light_outlineVariant = Color(0xFFBFC9C2)
val md_theme_light_inverseSurface = Color(0xFF2E312E)
val md_theme_light_inverseOnSurface = Color(0xFFF0F1ED)
val md_theme_light_inversePrimary = Color(0xFF88D6B8)
val md_theme_light_surfaceTint = Color(0xFF1A6B52)
// Material 3 dark scheme colors
val md_theme_dark_primary = Color(0xFF88D6B8)
val md_theme_dark_onPrimary = Color(0xFF003828)
val md_theme_dark_primaryContainer = Color(0xFF00513C)
val md_theme_dark_onPrimaryContainer = Color(0xFFA4F2D3)
val md_theme_dark_secondary = Color(0xFFB3CCC0)
val md_theme_dark_onSecondary = Color(0xFF1F352B)
val md_theme_dark_secondaryContainer = Color(0xFF354B41)
val md_theme_dark_onSecondaryContainer = Color(0xFFCEE9DB)
val md_theme_dark_tertiary = Color(0xFFA7CCDF)
val md_theme_dark_onTertiary = Color(0xFF083544)
val md_theme_dark_tertiaryContainer = Color(0xFF254B5B)
val md_theme_dark_onTertiaryContainer = Color(0xFFC3E8FB)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF191C1A)
val md_theme_dark_onBackground = Color(0xFFE1E3DF)
val md_theme_dark_surface = Color(0xFF191C1A)
val md_theme_dark_onSurface = Color(0xFFE1E3DF)
val md_theme_dark_surfaceVariant = Color(0xFF404943)
val md_theme_dark_onSurfaceVariant = Color(0xFFBFC9C2)
val md_theme_dark_outline = Color(0xFF89938C)
val md_theme_dark_outlineVariant = Color(0xFF404943)
val md_theme_dark_inverseSurface = Color(0xFFE1E3DF)
val md_theme_dark_inverseOnSurface = Color(0xFF191C1A)
val md_theme_dark_inversePrimary = Color(0xFF1A6B52)
val md_theme_dark_surfaceTint = Color(0xFF88D6B8)
// Status colors
val StatusSuccess = Color(0xFF2E7D32)
val StatusWarning = Color(0xFFF57F17)
val StatusError = Color(0xFFD32F2F)
val StatusInfo = Color(0xFF1976D2)

View File

@@ -0,0 +1,98 @@
package com.example.androidbackupgui.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LightColorScheme = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
onError = md_theme_light_onError,
errorContainer = md_theme_light_errorContainer,
onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
outline = md_theme_light_outline,
outlineVariant = md_theme_light_outlineVariant,
inverseSurface = md_theme_light_inverseSurface,
inverseOnSurface = md_theme_light_inverseOnSurface,
inversePrimary = md_theme_light_inversePrimary,
surfaceTint = md_theme_light_surfaceTint,
)
private val DarkColorScheme = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
onError = md_theme_dark_onError,
errorContainer = md_theme_dark_errorContainer,
onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
outline = md_theme_dark_outline,
outlineVariant = md_theme_dark_outlineVariant,
inverseSurface = md_theme_dark_inverseSurface,
inverseOnSurface = md_theme_dark_inverseOnSurface,
inversePrimary = md_theme_dark_inversePrimary,
surfaceTint = md_theme_dark_surfaceTint,
)
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
// Set status bar colors to match theme
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.surface.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
content = content
)
}

View File

@@ -0,0 +1,91 @@
package com.example.androidbackupgui.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val AppTypography = Typography(
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 32.sp,
lineHeight = 40.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
lineHeight = 36.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M19,9h-4V3H9v6H5l7,7 7,-7zM5,18v2h14v-2H5z" />
</vector>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94 0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61l-2.01,-1.58zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6 3.6,1.62 3.6,3.6 -1.62,3.6 -3.6,3.6z" />
</vector>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M9,16h6v-6h4l-7,-7 -7,7h4zM5,18h14v2H5z" />
</vector>

View File

@@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?attr/colorSurface">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/topAppBar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorSurface"
app:title="@string/app_name"
app:titleCentered="true"
app:titleTextColor="?attr/colorOnSurface"
style="@style/Widget.Material3.Toolbar" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottomNav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurfaceContainer"
app:menu="@menu/bottom_nav"
app:labelVisibilityMode="labeled" />
</LinearLayout>

View File

@@ -1,158 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="@dimen/fragment_horizontal_padding"
android:background="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="应用备份"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.button.MaterialButton
android:id="@+id/scanButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="扫描应用"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="用户: "
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<Spinner
android:id="@+id/userSelector"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/sortAZButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="2dp"
android:text="A-Z"
android:textSize="11sp"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/sortSizeButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:text="大小"
android:textSize="11sp"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/selectAllButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:text="全选"
android:textSize="11sp"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/deselectAllButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="2dp"
android:text="取消全选"
android:textSize="11sp"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/showSystemSwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="显示系统应用"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:checked="false" />
</LinearLayout>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:indeterminate="true"
android:visibility="gone"
app:indicatorColor="?attr/colorPrimary"
app:trackColor="?attr/colorSurfaceVariant" />
<TextView
android:id="@+id/statusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:maxLines="3"
android:ellipsize="end"
android:text="点击扫描以载入应用列表"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/appList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="12dp"
android:clipToPadding="false"
android:paddingBottom="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/backupButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:enabled="false"
android:text="开始备份选中应用"
style="@style/Widget.Material3.Button" />
</LinearLayout>

View File

@@ -1,393 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:clipToPadding="false"
android:padding="@dimen/fragment_horizontal_padding">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- ═══════ 备份选项 ═══════ -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardElevation="0dp"
app:cardBackgroundColor="?attr/colorSurfaceContainer"
app:strokeWidth="0dp"
app:contentPadding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="备份选项"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:textColor="?attr/colorPrimary" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/backupModeSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="备份数据+安装包 (关闭则仅备份安装包)" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/backupUserDataSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="备份用户数据" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/backupObbSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="备份 OBB 外部数据包" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/backupWifiSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="备份 WiFi 设置" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/ignoreRunningSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="忽略运行中的应用" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- ═══════ 输出路径 ═══════ -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardElevation="0dp"
app:cardBackgroundColor="?attr/colorSurfaceContainer"
app:strokeWidth="0dp"
app:contentPadding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="输出路径"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:textColor="?attr/colorPrimary" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="输出路径 (留空使用默认)"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/outputPathEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="压缩算法 (zstd / tar)"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/compressionEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="zstd" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- ═══════ Restic 云端备份 ═══════ -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardElevation="0dp"
app:cardBackgroundColor="?attr/colorSurfaceContainer"
app:strokeWidth="0dp"
app:contentPadding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Restic 云端备份"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:textColor="?attr/colorPrimary" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/resticEnabledSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="启用 restic 增量去重 (需安装 restic 二进制)" />
<!-- Backend selector -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="存储位置"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?attr/colorOnSurface" />
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:scrollbars="none">
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/resticBackendGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/resticBackendLocal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="80dp"
android:text="本机"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/resticBackendWebdav"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="80dp"
android:text="WebDAV"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/resticBackendSmb"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="80dp"
android:text="SMB"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/resticBackendRestServer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="80dp"
android:text="REST"
style="@style/Widget.Material3.Button.TonalButton" />
</com.google.android.material.button.MaterialButtonToggleGroup>
</HorizontalScrollView>
<!-- Backend URL (WebDAV/SMB only) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/resticBackendUrlLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="WebDAV 地址 (https://host:port/path)"
android:visibility="gone"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/resticBackendUrlEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
<!-- SMB share name (SMB only) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/resticBackendShareLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="SMB 共享名称 (如 backup、shared)"
android:visibility="gone"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/resticBackendShareEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Backend user (WebDAV/SMB only) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/resticBackendUserLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="用户名"
android:visibility="gone"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/resticBackendUserEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Backend password (WebDAV/SMB only) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/resticBackendPassLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="密码"
android:visibility="gone"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/resticBackendPassEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<!-- SMB domain (SMB only, optional) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/resticBackendDomainLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="SMB 域 (可选,如 WORKGROUP)"
android:visibility="gone"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/resticBackendDomainEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Repo path (always shown) -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="仓库路径 (本机: /sdcard/restic-repo / 云端: 目录名如 android-backups)"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/resticRepoEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Repo encryption password (always shown) -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="仓库加密密码 (请妥善保管,遗失即无法恢复)"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/resticPasswordEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Computed repo URL -->
<TextView
android:id="@+id/resticComputedUrlText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<!-- Action buttons -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/initResticButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="初始化"
style="@style/Widget.Material3.Button.TextButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/resticStatsButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="统计"
android:visibility="gone"
style="@style/Widget.Material3.Button.TextButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/resticPruneButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="清理"
android:visibility="gone"
style="@style/Widget.Material3.Button.TextButton" />
</LinearLayout>
<TextView
android:id="@+id/resticStatusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.button.MaterialButton
android:id="@+id/saveConfigButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="保存配置"
style="@style/Widget.Material3.Button" />
<TextView
android:id="@+id/configStatusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</ScrollView>

View File

@@ -1,112 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="@dimen/fragment_horizontal_padding"
android:background="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="应用恢复"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.button.MaterialButton
android:id="@+id/selectDirButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="本地"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/selectResticButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Restic"
android:visibility="gone"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="用户: "
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<Spinner
android:id="@+id/userSelector"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
<TextView
android:id="@+id/backupDirText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:ellipsize="middle"
android:singleLine="true"
android:text="未选择备份目录"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:indeterminate="true"
android:visibility="gone"
app:indicatorColor="?attr/colorPrimary"
app:trackColor="?attr/colorSurfaceVariant" />
<TextView
android:id="@+id/statusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:maxLines="3"
android:ellipsize="end"
android:text="请先选择备份文件夹"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/appList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="12dp"
android:clipToPadding="false"
android:paddingBottom="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/restoreButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:enabled="false"
android:text="开始恢复选中应用"
style="@style/Widget.Material3.Button" />
</LinearLayout>

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/nav_backup"
android:icon="@drawable/ic_backup"
android:title="备份" />
<item
android:id="@+id/nav_restore"
android:icon="@drawable/ic_restore"
android:title="恢复" />
<item
android:id="@+id/nav_config"
android:icon="@drawable/ic_config"
android:title="配置" />
</menu>

View File

@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Primary -->
<item name="colorPrimary">@color/primary</item>
<item name="colorOnPrimary">@color/onPrimary</item>
<item name="colorPrimaryContainer">@color/primaryContainer</item>
<item name="colorOnPrimaryContainer">@color/onPrimaryContainer</item>
<item name="colorPrimaryInverse">@color/inverseSurface</item>
<!-- Secondary -->
<item name="colorSecondary">@color/secondary</item>
<item name="colorOnSecondary">@color/onSecondary</item>
<item name="colorSecondaryContainer">@color/secondaryContainer</item>
<item name="colorOnSecondaryContainer">@color/onSecondaryContainer</item>
<!-- Tertiary -->
<item name="colorTertiary">@color/tertiary</item>
<item name="colorOnTertiary">@color/onTertiary</item>
<item name="colorTertiaryContainer">@color/tertiaryContainer</item>
<item name="colorOnTertiaryContainer">@color/onTertiaryContainer</item>
<!-- Error -->
<item name="colorError">@color/error</item>
<item name="colorOnError">@color/onError</item>
<item name="colorErrorContainer">@color/errorContainer</item>
<item name="colorOnErrorContainer">@color/onErrorContainer</item>
<!-- Surface / Background -->
<item name="android:colorBackground">@color/background</item>
<item name="colorOnBackground">@color/onBackground</item>
<item name="colorSurface">@color/surface</item>
<item name="colorOnSurface">@color/onSurface</item>
<item name="colorSurfaceVariant">@color/surfaceVariant</item>
<item name="colorOnSurfaceVariant">@color/onSurfaceVariant</item>
<item name="colorSurfaceInverse">@color/inverseSurface</item>
<item name="colorOnSurfaceInverse">@color/inverseOnSurface</item>
<!-- Outline -->
<item name="colorOutline">@color/outline</item>
<item name="colorOutlineVariant">@color/outlineVariant</item>
<!-- Surface container hierarchy -->
<item name="colorSurfaceContainerLowest">@color/surfaceContainerLowest</item>
<item name="colorSurfaceContainerLow">@color/surfaceContainerLow</item>
<item name="colorSurfaceContainer">@color/surfaceContainer</item>
<item name="colorSurfaceContainerHigh">@color/surfaceContainerHigh</item>
<item name="colorSurfaceContainerHighest">@color/surfaceContainerHighest</item>
<!-- Display cutout: allow content under punch-hole/notch, inset listener handles padding -->
<item name="android:windowLayoutInDisplayCutoutMode">always</item>
<!-- Status bar — dark theme -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View File

@@ -1,51 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- MD3 Primary (dark) -->
<color name="primary">#9ECAFF</color>
<color name="onPrimary">#003258</color>
<color name="primaryContainer">#00497D</color>
<color name="onPrimaryContainer">#D1E4FF</color>
<!-- MD3 Secondary (dark) -->
<color name="secondary">#FFB870</color>
<color name="onSecondary">#4A2800</color>
<color name="secondaryContainer">#6A3C00</color>
<color name="onSecondaryContainer">#FFDCB5</color>
<!-- MD3 Tertiary (dark) -->
<color name="tertiary">#8CD4C4</color>
<color name="onTertiary">#00382F</color>
<color name="tertiaryContainer">#005045</color>
<color name="onTertiaryContainer">#A7F0DE</color>
<!-- MD3 Error (dark) -->
<color name="error">#FFB4AB</color>
<color name="onError">#690005</color>
<color name="errorContainer">#93000A</color>
<color name="onErrorContainer">#FFDAD6</color>
<!-- MD3 Surface / Background (dark) -->
<color name="background">#1A1C1E</color>
<color name="onBackground">#E2E2E6</color>
<color name="surface">#1A1C1E</color>
<color name="onSurface">#E2E2E6</color>
<color name="surfaceVariant">#43474E</color>
<color name="onSurfaceVariant">#C3C6CF</color>
<color name="inverseSurface">#E2E2E6</color>
<color name="inverseOnSurface">#2F3033</color>
<!-- MD3 Outline (dark) -->
<color name="outline">#8D9199</color>
<color name="outlineVariant">#43474E</color>
<!-- Surface container hierarchy (dark) -->
<color name="surfaceContainerLowest">#0E1114</color>
<color name="surfaceContainerLow">#1A1C1E</color>
<color name="surfaceContainer">#1E2023</color>
<color name="surfaceContainerHigh">#292A2E</color>
<color name="surfaceContainerHighest">#333539</color>
<!-- Legacy console colors -->
<color name="consoleBg">#102027</color>
<color name="consoleText">#ECEFF1</color>
</resources>

View File

@@ -1,54 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Primary -->
<item name="colorPrimary">@color/primary</item>
<item name="colorOnPrimary">@color/onPrimary</item>
<item name="colorPrimaryContainer">@color/primaryContainer</item>
<item name="colorOnPrimaryContainer">@color/onPrimaryContainer</item>
<!-- Secondary -->
<item name="colorSecondary">@color/secondary</item>
<item name="colorOnSecondary">@color/onSecondary</item>
<item name="colorSecondaryContainer">@color/secondaryContainer</item>
<item name="colorOnSecondaryContainer">@color/onSecondaryContainer</item>
<!-- Tertiary -->
<item name="colorTertiary">@color/tertiary</item>
<item name="colorOnTertiary">@color/onTertiary</item>
<item name="colorTertiaryContainer">@color/tertiaryContainer</item>
<item name="colorOnTertiaryContainer">@color/onTertiaryContainer</item>
<!-- Error -->
<item name="colorError">@color/error</item>
<item name="colorOnError">@color/onError</item>
<item name="colorErrorContainer">@color/errorContainer</item>
<item name="colorOnErrorContainer">@color/onErrorContainer</item>
<!-- Surface / Background -->
<item name="android:colorBackground">@color/background</item>
<item name="colorOnBackground">@color/onBackground</item>
<item name="colorSurface">@color/surface</item>
<item name="colorOnSurface">@color/onSurface</item>
<item name="colorSurfaceVariant">@color/surfaceVariant</item>
<item name="colorOnSurfaceVariant">@color/onSurfaceVariant</item>
<item name="colorSurfaceInverse">@color/inverseSurface</item>
<item name="colorOnSurfaceInverse">@color/inverseOnSurface</item>
<!-- Outline -->
<item name="colorOutline">@color/outline</item>
<item name="colorOutlineVariant">@color/outlineVariant</item>
<!-- Surface container hierarchy -->
<item name="colorSurfaceContainerLowest">@color/surfaceContainerLowest</item>
<item name="colorSurfaceContainerLow">@color/surfaceContainerLow</item>
<item name="colorSurfaceContainer">@color/surfaceContainer</item>
<item name="colorSurfaceContainerHigh">@color/surfaceContainerHigh</item>
<item name="colorSurfaceContainerHighest">@color/surfaceContainerHighest</item>
<!-- Status bar — dark theme -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Card dimensions (tablet: wider layout, larger touch targets) -->
<dimen name="card_padding_horizontal">24dp</dimen>
<dimen name="card_padding_vertical">16dp</dimen>
<dimen name="card_radius">16dp</dimen>
<dimen name="card_margin_bottom">12dp</dimen>
<!-- List item text size -->
<dimen name="list_item_text_size">18sp</dimen>
<!-- Fragment layout padding -->
<dimen name="fragment_horizontal_padding">24dp</dimen>
<!-- Bottom navigation: inset padding from system bars (set dynamically) -->
<dimen name="bottom_nav_padding_bottom">0dp</dimen>
</resources>

View File

@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Primary -->
<item name="colorPrimary">@color/primary</item>
<item name="colorOnPrimary">@color/onPrimary</item>
<item name="colorPrimaryContainer">@color/primaryContainer</item>
<item name="colorOnPrimaryContainer">@color/onPrimaryContainer</item>
<item name="colorPrimaryInverse">@color/inverseSurface</item>
<!-- Secondary -->
<item name="colorSecondary">@color/secondary</item>
<item name="colorOnSecondary">@color/onSecondary</item>
<item name="colorSecondaryContainer">@color/secondaryContainer</item>
<item name="colorOnSecondaryContainer">@color/onSecondaryContainer</item>
<!-- Tertiary -->
<item name="colorTertiary">@color/tertiary</item>
<item name="colorOnTertiary">@color/onTertiary</item>
<item name="colorTertiaryContainer">@color/tertiaryContainer</item>
<item name="colorOnTertiaryContainer">@color/onTertiaryContainer</item>
<!-- Error -->
<item name="colorError">@color/error</item>
<item name="colorOnError">@color/onError</item>
<item name="colorErrorContainer">@color/errorContainer</item>
<item name="colorOnErrorContainer">@color/onErrorContainer</item>
<!-- Surface / Background -->
<item name="android:colorBackground">@color/background</item>
<item name="colorOnBackground">@color/onBackground</item>
<item name="colorSurface">@color/surface</item>
<item name="colorOnSurface">@color/onSurface</item>
<item name="colorSurfaceVariant">@color/surfaceVariant</item>
<item name="colorOnSurfaceVariant">@color/onSurfaceVariant</item>
<item name="colorSurfaceInverse">@color/inverseSurface</item>
<item name="colorOnSurfaceInverse">@color/inverseOnSurface</item>
<!-- Outline -->
<item name="colorOutline">@color/outline</item>
<item name="colorOutlineVariant">@color/outlineVariant</item>
<!-- Surface container hierarchy -->
<item name="colorSurfaceContainerLowest">@color/surfaceContainerLowest</item>
<item name="colorSurfaceContainerLow">@color/surfaceContainerLow</item>
<item name="colorSurfaceContainer">@color/surfaceContainer</item>
<item name="colorSurfaceContainerHigh">@color/surfaceContainerHigh</item>
<item name="colorSurfaceContainerHighest">@color/surfaceContainerHighest</item>
<!-- Display cutout: allow content under punch-hole/notch, inset listener handles padding -->
<item name="android:windowLayoutInDisplayCutoutMode">always</item>
<!-- Status bar -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>

View File

@@ -1,51 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- MD3 Primary — deep blue -->
<color name="primary">#1565C0</color>
<color name="onPrimary">#FFFFFF</color>
<color name="primaryContainer">#D1E4FF</color>
<color name="onPrimaryContainer">#001D36</color>
<!-- MD3 Secondary — amber accent -->
<color name="secondary">#FF8F00</color>
<color name="onSecondary">#FFFFFF</color>
<color name="secondaryContainer">#FFDCB5</color>
<color name="onSecondaryContainer">#2D1800</color>
<!-- MD3 Tertiary — teal -->
<color name="tertiary">#00796B</color>
<color name="onTertiary">#FFFFFF</color>
<color name="tertiaryContainer">#A7F0DE</color>
<color name="onTertiaryContainer">#001F19</color>
<!-- MD3 Error -->
<color name="error">#BA1A1A</color>
<color name="onError">#FFFFFF</color>
<color name="errorContainer">#FFDAD6</color>
<color name="onErrorContainer">#410002</color>
<!-- MD3 Surface / Background (light) -->
<color name="background">#FDFCFF</color>
<color name="onBackground">#1A1C1E</color>
<color name="surface">#FDFCFF</color>
<color name="onSurface">#1A1C1E</color>
<color name="surfaceVariant">#DFE2EB</color>
<color name="onSurfaceVariant">#43474E</color>
<color name="inverseSurface">#2F3033</color>
<color name="inverseOnSurface">#F1F0F4</color>
<!-- MD3 Outline -->
<color name="outline">#73777F</color>
<color name="outlineVariant">#C3C6CF</color>
<!-- Surface container hierarchy (light) -->
<color name="surfaceContainerLowest">#FFFFFF</color>
<color name="surfaceContainerLow">#F7F9FC</color>
<color name="surfaceContainer">#F1F3F7</color>
<color name="surfaceContainerHigh">#EBEDF1</color>
<color name="surfaceContainerHighest">#E6E8EB</color>
<!-- Legacy console colors -->
<color name="consoleBg">#263238</color>
<color name="consoleText">#ECEFF1</color>
<color name="primary">#FF1A6B52</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Card dimensions (phone baseline) -->
<dimen name="card_padding_horizontal">16dp</dimen>
<dimen name="card_padding_vertical">12dp</dimen>
<dimen name="card_radius">12dp</dimen>
<dimen name="card_margin_bottom">8dp</dimen>
<!-- List item text size -->
<dimen name="list_item_text_size">15sp</dimen>
<!-- Fragment layout padding -->
<dimen name="fragment_horizontal_padding">16dp</dimen>
<!-- Bottom navigation: inset padding from system bars (set dynamically) -->
<dimen name="bottom_nav_padding_bottom">0dp</dimen>
</resources>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="checkbox" type="id" />
<item name="appName" type="id" />
</resources>

View File

@@ -13,4 +13,5 @@
<string name="status_done">完成 (退出码: %d)</string>
<string name="status_error">执行失败: %s</string>
<string name="status_cancelled">已取消</string>
<string name="exclude_data_toggle">切换数据排除</string>
</resources>

View File

@@ -1,55 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Primary -->
<item name="colorPrimary">@color/primary</item>
<item name="colorOnPrimary">@color/onPrimary</item>
<item name="colorPrimaryContainer">@color/primaryContainer</item>
<item name="colorOnPrimaryContainer">@color/onPrimaryContainer</item>
<item name="colorPrimaryInverse">@color/inverseSurface</item>
<!-- Secondary -->
<item name="colorSecondary">@color/secondary</item>
<item name="colorOnSecondary">@color/onSecondary</item>
<item name="colorSecondaryContainer">@color/secondaryContainer</item>
<item name="colorOnSecondaryContainer">@color/onSecondaryContainer</item>
<!-- Tertiary -->
<item name="colorTertiary">@color/tertiary</item>
<item name="colorOnTertiary">@color/onTertiary</item>
<item name="colorTertiaryContainer">@color/tertiaryContainer</item>
<item name="colorOnTertiaryContainer">@color/onTertiaryContainer</item>
<!-- Error -->
<item name="colorError">@color/error</item>
<item name="colorOnError">@color/onError</item>
<item name="colorErrorContainer">@color/errorContainer</item>
<item name="colorOnErrorContainer">@color/onErrorContainer</item>
<!-- Surface / Background -->
<item name="android:colorBackground">@color/background</item>
<item name="colorOnBackground">@color/onBackground</item>
<item name="colorSurface">@color/surface</item>
<item name="colorOnSurface">@color/onSurface</item>
<item name="colorSurfaceVariant">@color/surfaceVariant</item>
<item name="colorOnSurfaceVariant">@color/onSurfaceVariant</item>
<item name="colorSurfaceInverse">@color/inverseSurface</item>
<item name="colorOnSurfaceInverse">@color/inverseOnSurface</item>
<!-- Outline -->
<item name="colorOutline">@color/outline</item>
<item name="colorOutlineVariant">@color/outlineVariant</item>
<!-- Surface container hierarchy -->
<item name="colorSurfaceContainerLowest">@color/surfaceContainerLowest</item>
<item name="colorSurfaceContainerLow">@color/surfaceContainerLow</item>
<item name="colorSurfaceContainer">@color/surfaceContainer</item>
<item name="colorSurfaceContainerHigh">@color/surfaceContainerHigh</item>
<item name="colorSurfaceContainerHighest">@color/surfaceContainerHighest</item>
<!-- Status bar -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
</style>
<!-- Minimal theme for AndroidManifest (UI theme managed by Compose) -->
<style name="AppTheme" parent="Theme.Material3.DayNight.NoActionBar" />
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include domain="file" path="."/>
</cloud-backup>
<device-transfer>
<include domain="file" path="."/>
</device-transfer>
</data-extraction-rules>

View File

@@ -0,0 +1,23 @@
package android.util;
/**
* Test-only stub for android.util.Log.
* Prevents RuntimeException("Stub!") from android.jar during JVM unit tests.
*/
public final class Log {
public static int v(String tag, String msg) { return 0; }
public static int v(String tag, String msg, Throwable tr) { return 0; }
public static int d(String tag, String msg) { return 0; }
public static int d(String tag, String msg, Throwable tr) { return 0; }
public static int i(String tag, String msg) { return 0; }
public static int i(String tag, String msg, Throwable tr) { return 0; }
public static int w(String tag, String msg) { return 0; }
public static int w(String tag, String msg, Throwable tr) { return 0; }
public static int e(String tag, String msg) { return 0; }
public static int e(String tag, String msg, Throwable tr) { return 0; }
public static int wtf(String tag, String msg) { return 0; }
public static int wtf(String tag, String msg, Throwable tr) { return 0; }
public static String getStackTraceString(Throwable tr) { return ""; }
public static boolean isLoggable(String tag, int level) { return false; }
public static int println(int priority, String tag, String msg) { return 0; }
}

View File

@@ -0,0 +1,273 @@
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.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.kotest.property.Arb
import io.kotest.property.arbitrary.int
import io.kotest.property.arbitrary.string
import io.kotest.property.checkAll
import java.io.IOException
class AppErrorTest : FunSpec({
context("AppError.Network") {
test("has correct defaults") {
val error = AppError.Network("connection timeout")
error.message shouldBe "connection timeout"
error.cause.shouldBeNull()
error.retryable shouldBe true
}
test("preserves cause and retryable overrides") {
val cause = RuntimeException("DNS failed")
val error = AppError.Network("DNS resolution failed", cause = cause, retryable = false)
error.cause shouldBe cause
error.retryable shouldBe false
}
test("property: message is preserved") {
checkAll(Arb.string(1..200)) { msg ->
val error = AppError.Network(msg)
error.message shouldBe msg
}
}
}
context("AppError.Remote") {
test("preserves phase, cause, isNotFound, retryable") {
val cause = RuntimeException("underlying error")
val error = AppError.Remote("upload failed", "upload", cause = cause)
error.message shouldBe "upload failed"
error.phase shouldBe "upload"
error.cause shouldBe cause
error.isNotFound shouldBe false
error.retryable shouldBe false
}
test("with isNotFound=true") {
val error = AppError.Remote("not found", "list", isNotFound = true)
error.isNotFound shouldBe true
}
}
context("AppError.Shell") {
test("preserves command, exitCode, and stderr") {
val error = AppError.Shell("cp failed", "cp /a /b", 1, "permission denied")
error.message shouldBe "cp failed"
error.command shouldBe "cp /a /b"
error.exitCode shouldBe 1
error.stderr shouldBe "permission denied"
}
}
context("AppError.LocalIO") {
test("preserves path and optional cause") {
val error = AppError.LocalIO("file not found", "/data/test.txt")
error.message shouldBe "file not found"
error.path shouldBe "/data/test.txt"
error.cause.shouldBeNull()
}
test("preserves cause when provided") {
val cause = IOException("disk full")
val error = AppError.LocalIO("write failed", "/data/test.txt", cause = cause)
error.cause shouldBe cause
}
}
context("AppError.Restic") {
test("preserves exit code and stderr") {
val error = AppError.Restic("restic failed", 1, "permission denied")
error.message shouldBe "restic failed"
error.exitCode shouldBe 1
error.stderr shouldBe "permission denied"
}
test("property: any exit code is preserved") {
checkAll(Arb.int()) { code ->
val error = AppError.Restic("err", code, "stderr output")
error.exitCode shouldBe code
}
}
}
context("AppError.Parse") {
test("preserves message and detail") {
val error = AppError.Parse("bad json", "expected '{'")
error.message shouldBe "bad json"
error.detail shouldBe "expected '{'"
}
test("detail defaults to empty string") {
val error = AppError.Parse("bad json")
error.detail shouldBe ""
}
}
context("AppError.Cancelled") {
test("is a data object with fixed message") {
val error = AppError.Cancelled
error.message shouldBe "操作被取消"
// Verify singleton behavior
val error2 = AppError.Cancelled
error shouldBe error2
}
}
context("AppResult.Success") {
test("holds a value") {
val result: AppResult<String> = AppResult.Success("hello")
result.isSuccess shouldBe true
result.isFailure shouldBe false
result.getOrNull() shouldBe "hello"
result.getOrDefault("fallback") shouldBe "hello"
result.getOrThrow() shouldBe "hello"
result.exceptionOrNull().shouldBeNull()
result.errorOrNull().shouldBeNull()
}
test("fold calls onSuccess") {
val result: AppResult<Int> = AppResult.Success(42)
val folded =
result.fold(
onSuccess = { it * 2 },
onFailure = { 0 },
)
folded shouldBe 84
}
test("map transforms value") {
val result: AppResult<Int> = AppResult.Success(42)
val mapped = result.map { it.toString() }
mapped shouldBe AppResult.Success("42")
}
test("mapError passes through success") {
val result: AppResult<Int> = AppResult.Success(42)
val mapped = result.mapError { AppError.Parse("should not happen") }
mapped shouldBe AppResult.Success(42)
}
}
context("AppResult.Failure via err()") {
test("creates failure result") {
val result: AppResult<String> = err(AppError.Parse("bad json"))
result.isSuccess shouldBe false
result.isFailure shouldBe true
result.getOrNull().shouldBeNull()
result.getOrDefault("fallback") shouldBe "fallback"
result.errorOrNull() shouldBe AppError.Parse("bad json")
result.errorOrNull()?.message shouldBe "bad json"
}
test("exceptionOrNull returns RuntimeException with AppError message") {
val result: AppResult<String> = err(AppError.Parse("bad json"))
val ex = result.exceptionOrNull()
ex.shouldBeInstanceOf<RuntimeException>()
ex?.message shouldBe "bad json"
}
test("getOrThrow throws RuntimeException") {
val result: AppResult<String> = err(AppError.Parse("bad json"))
val ex = shouldThrow<RuntimeException> { result.getOrThrow() }
ex.message shouldBe "bad json"
}
test("wraps any AppError subtype") {
val errors =
listOf(
AppError.Network("net err"),
AppError.Remote("remote err", "connect"),
AppError.Shell("shell err", "ls", 1, ""),
AppError.LocalIO("io err", "/tmp"),
AppError.Restic("restic err", 1, ""),
AppError.Parse("parse err"),
AppError.Cancelled,
)
errors.forEach { error ->
val result: AppResult<Unit> = err(error)
result.isFailure shouldBe true
result.errorOrNull()?.message shouldBe error.message
}
}
}
context("AppResult.Failure direct") {
test("holds an error") {
val error = AppError.Network("network error")
val result: AppResult<String> = AppResult.Failure(error)
result.isSuccess shouldBe false
result.isFailure shouldBe true
result.errorOrNull() shouldBe error
}
test("fold calls onFailure") {
val result: AppResult<Int> = AppResult.Failure(AppError.Parse("parse failed"))
val folded =
result.fold(
onSuccess = { 0 },
onFailure = { error -> error.message.length },
)
folded shouldBe "parse failed".length
}
test("map passes through failure") {
val error = AppError.Parse("no data")
val result: AppResult<Int> = AppResult.Failure(error)
val mapped = result.map { it + 1 }
mapped shouldBe AppResult.Failure(error)
}
test("mapError transforms error") {
val result: AppResult<Int> = AppResult.Failure(AppError.Parse("old error"))
val mapped = result.mapError { AppError.Remote("mapped: ${it.message}", "transform") }
mapped.errorOrNull()?.message shouldBe "mapped: old error"
}
}
context("AppResult exhaustive when") {
test("can pattern match with is AppResult.Success") {
val result: AppResult<String> = AppResult.Success("data")
val output =
when (result) {
is AppResult.Success -> "got: ${result.data}"
is AppResult.Failure -> "err: ${result.error.message}"
}
output shouldBe "got: data"
}
test("can pattern match with is AppResult.Failure") {
val result: AppResult<String> = AppResult.Failure(AppError.Cancelled)
val output =
when (result) {
is AppResult.Success -> "got: ${result.data}"
is AppResult.Failure -> "err: ${result.error.message}"
}
output shouldBe "err: 操作被取消"
}
}
context("AppResult type inference") {
test("AppResult.Success with Unit") {
val result: AppResult<Unit> = AppResult.Success(Unit)
result.isSuccess shouldBe true
result.getOrDefault(Unit) shouldBe Unit
}
test("AppResult.Failure with Nothing") {
val result: AppResult<Int> = AppResult.Failure(AppError.Cancelled)
result.isFailure shouldBe true
}
}
context("err function short-form") {
test("err() returns AppResult.Failure") {
val result: AppResult<String> = err(AppError.Remote("upload failed", "upload"))
result.shouldBeInstanceOf<AppResult.Failure>()
(result as AppResult.Failure).error shouldBe AppError.Remote("upload failed", "upload")
}
}
})

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

@@ -0,0 +1,44 @@
package com.example.androidbackupgui.backup
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.io.File
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()
}
}
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("backend pass is stored as placeholder (actual pass in PasswordManager)") {
val c = BackupConfig(resticBackendPass = "secret")
roundTrip(c).resticBackendPass shouldBe ""
}
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"
}
})

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"
}
}
})

View File

@@ -0,0 +1,13 @@
package com.example.androidbackupgui.backup
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
class ResticBinaryTest : FunSpec({
context("ResticBinary") {
test("isReady returns false before prepare is called") {
ResticBinary.isReady() shouldBe false
}
}
})

View File

@@ -0,0 +1,110 @@
package com.example.androidbackupgui.backup
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.int
import io.kotest.property.arbitrary.list
import io.kotest.property.arbitrary.string
import io.kotest.property.checkAll
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class ResticCommandRunnerTest : FunSpec({
val defaultRunner = ResticCommandRunner()
context("buildCommandArgs") {
test("prepends default binary path") {
val args = defaultRunner.buildCommandArgs(listOf("init", "--json"))
args shouldBe listOf("restic", "init", "--json")
}
test("uses custom binary path") {
val runner = ResticCommandRunner()
runner.binaryPath = "/data/data/com.termux/files/usr/bin/restic"
val args = runner.buildCommandArgs(listOf("backup", "/sdcard"))
args shouldBe
listOf(
"/data/data/com.termux/files/usr/bin/restic",
"backup",
"/sdcard",
)
}
test("returns empty args list when called with empty list") {
val args = defaultRunner.buildCommandArgs(emptyList())
args shouldBe listOf("restic")
}
test("preserves argument order") {
val runner = ResticCommandRunner()
runner.binaryPath = "restic"
val args = runner.buildCommandArgs(listOf("a", "b", "c"))
args shouldBe listOf("restic", "a", "b", "c")
}
test("property: any list of string args mainatains length") {
checkAll(Arb.list(Arb.string(1..20), 0..10)) { inputArgs ->
val args = defaultRunner.buildCommandArgs(inputArgs)
args shouldHaveSize (inputArgs.size + 1)
args[0] shouldBe "restic"
}
}
}
context("runRestic(vararg)") {
test("delegates to runRestic(List) and returns failure on nonexistent binary") {
val runner = ResticCommandRunner()
runner.binaryPath = "/nonexistent/restic"
val result = runner.runRestic(mapOf("RESTIC_REPOSITORY" to "/tmp/repo"), "version")
result.exitCode shouldBe -1
result.stdout shouldBe ""
}
}
context("CommandResult serialization") {
test("serializes and deserializes correctly") {
val original =
ResticCommandRunner.CommandResult(
stdout = "some output",
stderr = "",
exitCode = 0,
)
val json = Json.encodeToString(original)
val decoded = Json.decodeFromString<ResticCommandRunner.CommandResult>(json)
decoded.stdout shouldBe "some output"
decoded.stderr shouldBe ""
decoded.exitCode shouldBe 0
}
test("roundtrip property: preserves exit code") {
checkAll(Arb.int()) { code ->
val original =
ResticCommandRunner.CommandResult(
stdout = "out",
stderr = code.toString(),
exitCode = code,
)
val json = Json.encodeToString(original)
val decoded = Json.decodeFromString<ResticCommandRunner.CommandResult>(json)
decoded.exitCode shouldBe code
decoded.stderr shouldBe code.toString()
}
}
}
context("ResticCommandRunner instantiation") {
test("default binary path is restic") {
defaultRunner.binaryPath shouldBe "restic"
}
test("can set custom binary path") {
val runner = ResticCommandRunner()
runner.binaryPath = "/custom/path/restic"
runner.binaryPath shouldBe "/custom/path/restic"
}
}
})

88
docs/plans/roadmap.md Normal file
View File

@@ -0,0 +1,88 @@
# Android Backup GUI — 后续路线图
## 已完成(当前版本)
| 领域 | 变更 | 阶段 |
|------|------|------|
| 🔒 安全 | 配置文件权限加固、签名密码加固 | P1 |
| 🔒 安全 | SMB MD4/AESCMAC 算法注入修复 + 全局注册 | Hotfix |
| 🐛 正确性 | `CancellationException` 透传 × 8 处 | P2 |
| 🐛 正确性 | SMB/WebDAV 返回 `Failure` 而非 `Success` | P2 |
| 🐛 正确性 | `BackupOperation.backupUserData` 全失败返回 `false` | P2 |
| 🐛 正确性 | `RestoreOperation` 改用 `supervisorScope` | P2 |
| 🐛 正确性 | `ResticCommandRunner` NPE 修复2 处 readLine 模式) | Hotfix |
| 🌐 网络 | SMB/WebDAV 下载/上传自动重试 3 次 + 指数退避 | Hotfix |
| 🌐 网络 | WebDAV Range 断点续传(`.part` 文件 + HTTP Range | Hotfix |
| 🏗️ 构建 | ResticRestBridge 绑定 127.0.0.1 | P3 |
| 🏗️ 构建 | `allowBackup=false` | P3 |
| 🏗️ 构建 | CI 添加 test 步骤 | P3 |
| 🧹 清理 | 删除 `MD4Provider.kt`、3 个死方法、`DataSizes``isFileNotFound``getAppLabel` | P4 |
---
## 下一阶段规划
### Phase A: 稳定性与恢复可靠性3-5 天)
| # | 工作 | 文件 | 说明 | 风险 |
|---|------|------|------|------|
| A1 | 恢复操作 Fragment 修复 | `RestoreFragment.kt` | 添加 `onDestroyView` 防止视图分离后更新 UI | 低 |
| A2 | BackupFragment 修复 | `BackupFragment.kt` | 添加 `onDestroyView`,清理协程 | 低 |
| A3 | ResticRestBridge 认证 | `ResticRestBridge.kt` | 添加 token 认证,防止端口暴露 | 低 |
| A4 | WebDAV 超时可配置 | `WebdavTransport.kt` | Sardine 连接/读取超时通过构造参数设置 | 低 |
| A5 | tar 路径遍历检查 | `SELinuxUtil.kt` | `isArchiveSafe` 添加绝对路径检查 | 低 |
| A6 | 恢复后缓存清理 | `ResticRestBridge.kt` | restore 完成后清理 `restic_blob_*` 缓存文件 | 低 |
### Phase B: 遗留死代码与重构2-3 天)
| # | 工作 | 文件 | 说明 | 风险 |
|---|------|------|------|------|
| B1 | 冗余导入清理 | 7 个文件 | 同包 `import` 冗余 | 低 |
| B2 | 未使用导入清理 | 5 个文件 | 删除无引用 import | 低 |
| B3 | 未使用参数清理 | 3 个函数 | 删除 `@Suppress("UNUSED_PARAMETER")` | 低 |
| B4 | TAG 修复 | `ResticRepoInit.kt`, `ResticCommandRunner.kt` | TAG 变量改为类名 | 低 |
| B5 | UID 解析提取 | `BackupOperation.kt`, `StreamingBackup.kt` | 重复的 UID 解析逻辑提取公共函数 | 低 |
| B6 | 5 个子模块重复分支提取 | `ResticBackup.kt` 等 | if-else local/remote 分支模式提取公共执行函数 | 中 |
### Phase C: 功能增强5-7 天)
| # | 工作 | 文件 | 说明 | 风险 |
|---|------|------|------|------|
| C1 | 多目录恢复选择 | `RestoreFragment.kt` | 让用户选择从哪个 snapshot 恢复哪些目录 | 中 |
| C2 | 前台服务 | `BackupService.kt` | 备份/恢复时启动前台服务防止杀进程 | 中 |
| C3 | 多用户支持 | 全局 | `userId` 参数全面传递到 restore 流程 | 中 |
| C4 | 恢复进度细化 | `RestoreOperation.kt` | 每 blob 粒度进度回调 | 低 |
### Phase D: 安全加固3-4 天)
| # | 工作 | 文件 | 说明 | 风险 |
|---|------|------|------|------|
| D1 | 密码加密存储 | `BackupConfig.kt` | EncryptedSharedPreferences + 迁移现有配置 | 中 |
| D2 | 仓库密码 UI 掩码 | `ConfigFragment.kt` | 确认/二次输入 | 低 |
### Phase E: 类型安全大重构2-3 天)
| # | 工作 | 文件 | 说明 | 风险 |
|---|------|------|------|------|
| E1 | `PackageName` 全面采用 | 8+ 文件 | 函数参数 `String``PackageName` | 高 |
| E2 | `UserId` 全面采用 | 8+ 文件 | 函数参数 `String`/`Int``UserId` | 高 |
### Phase F: i18n 国际化2-3 天)
| # | 工作 | 文件 | 说明 | 风险 |
|---|------|------|------|------|
| F1 | strings.xml 提取 | 所有 UI 文件 | 将硬编码中文提取到 strings.xml | 低 |
| F2 | en/ 翻译 | strings.xml | 英文 strings.xml | 低 |
---
## 建议执行顺序
1. **Phase A**(稳定性优先 — 当前测试中暴露的问题优先修)
2. **Phase B**(清理干净再动大重构)
3. **Phase C**(用户可见功能)
4. **Phase D**(安全加固)
5. **Phase E**(类型安全 — 大重构,和 C 可能有冲突)
6. **Phase F**(最后做,纯文案)
**Phase A + B 可并行执行**

View File

@@ -0,0 +1,247 @@
# Android Backup GUI — 全面审查报告
**审查日期**: 2026-06-06
**审查范围**: 37 个 Kotlin 源文件 + 8 个布局/资源 XML + AndroidManifest
**当前状态**: 53 测试全通过lint 0 错误,编译成功
**已知问题排除**: 7 项 memory 记录的待处理项已跳过
---
## 严重程度说明
| 等级 | 定义 |
|------|------|
| **CRITICAL** | 可直接导致数据泄露、root 提权、静默数据损坏。必须立即修复 |
| **HIGH** | 特定条件下可导致敏感数据泄露、错误处理失效或功能严重受限 |
| **MEDIUM** | 风险较低或需复杂攻击链,但应规划修复 |
| **LOW** | 可改进点,非阻塞 |
| **INFO** | 建议性质,无实际风险 |
---
## 审查方法
使用 11 个 ECC 审查技能分三层并行执行:
| 层级 | 技能 | 方向 |
|------|------|------|
| 第一层:安全与正确性 | ecc-security-reviewer, security-review, ecc-silent-failure-hunter | 漏洞检测、输入校验、静默失败 |
| 第二层:架构与代码质量 | ecc-kotlin-reviewer, kotlin-coroutines-flows, ecc-type-design-analyzer, production-audit | 协程安全、类型设计、生产就绪 |
| 第三层:可维护性与用户体验 | ecc-comment-analyzer, ecc-refactor-cleaner, ecc-code-simplifier, accessibility | 死代码、简化、无障碍 |
---
## 发现汇总
| 层级 | 技能 | CRITICAL | HIGH | MEDIUM | LOW/INFO | 总计 |
|------|------|----------|------|--------|----------|------|
| 第一层 | 安全审查 | 2 | 2 | 5 | 3 | 12 |
| 第一层 | OWASP 安全审查 | 0 | 4 | 6 | 11 | 21 |
| 第一层 | 静默失败审查 | 0 | 4 | 12 | 9 | 25 |
| 第二层 | Kotlin 代码审查 | 0 | 1 | 6 | 19 | 26 |
| 第二层 | 协程/Flow 审查 | 0 | 2 | 4 | 5 | 11 |
| 第二层 | 类型设计审查 | 0 | 2 | 8 | 6 | 16 |
| 第二层 | 生产就绪审查 | 0 | 4 | 10 | 4 | 18 |
| 第三层 | 注释审查 | 0 | 0 | 2 | 4 | 6 |
| 第三层 | 死代码清理 | 0 | 4 | 4 | 4 | 12 |
| 第三层 | 代码简化 | 0 | 2 | 7 | 10 | 19 |
| 第三层 | 无障碍审查 | 1 | 4 | 8 | 2 | 15 |
| | **合计** | **3** | **29** | **72** | **77** | **181** |
---
## 顶层 CRITICAL 问题(必须立即修复)
### C1. 凭据明文存储在配置文件中
**文件**: `BackupConfig.kt:69,73,156-157`
**类型**: Secret 泄露
restic 密码和 SMB/WebDAV 凭据以明文写入 `backup_settings.conf`,位于 `filesDir`。root 环境下任何进程可读取。UI 中密码字段也未使用 `inputType="textPassword"`
**建议**: 使用 `EncryptedSharedPreferences`UI 使用密码掩码输入框。
### C2. 配置文件写入权限不安全
**文件**: `BackupConfig.kt:~144`
**类型**: 权限滥用
`file.writeText()` 使用系统默认文件权限,未显式设置 owner-only 权限。
**建议**: 保存后调用 `file.setReadable(true, true)` / `file.setWritable(true, true)`
### C3. TextView 模拟按钮缺少无障碍角色
**文件**: `PackageListAdapter.kt:61-69`
**类型**: 无障碍
"数据"排除切换使用纯 `TextView` 实现点击交互TalkBack 无法识别其可点击角色。
**建议**: 改用 `MaterialButton` 或添加 `focusable=true`, `clickable=true`, `contentDescription`
---
## HIGH 优先修复29 项)
### 安全(第一层汇总)
| # | 文件 | 行号 | 问题 | 建议 |
|---|------|------|------|------|
| H1 | `ResticRestBridge.kt` | 27 | NanoHTTPD 绑定 0.0.0.0 无认证,局域网可访问 | 改为 `NanoHTTPD("127.0.0.1", 0)` |
| H2 | `RestoreOperation.kt` | 137-149 | tar 解压使用 `-C /`,恶意存档可覆写系统文件 | 添加绝对路径检查,临时目录解压 |
| H3 | `SmbTransport.kt` | 103-109 | SMB 上传大小不匹配仍返回 Success数据可能静默损坏 | 不匹配时返回 `AppResult.Failure` |
| H4 | `BackupOperation.kt` | 255-257 | `backupUserData` 全失败时返回 `true`,用户看到"成功" | 改为 `return false` |
| H5 | `ResticBackup.kt` | 55-58, 73-77 | `CancellationException` 被空 `catch` 吞没,取消信号丢失 | 加 `catch (e: CancellationException) { throw e }` |
| H6 | `WebdavTransport.kt` | 153-155 | `mkdirs` 完全失败仍返回 `Success(Unit)` | 异常时应返回 `AppResult.Failure` |
| H7 | `RootShell.kt` | 84-87 | `CancellationException``catch (e: Exception)` 吞没,全局取消失效 | 加 `catch (e: CancellationException) { throw e }` |
| H8 | `ResticRestore.kt` | 78, 107 | 同 H5CancellationException 被空 catch 吞没 | 重新抛出 CancellationException |
| H9 | `BackupConfig.kt` | 69, 73 | 所有密码未加密存储OWASP 维度) | EncryptedSharedPreferences |
| H10 | `ui/ConfigViewModel.kt` | 180-183 | 空密码检测仅 `initResticRepo` 中有,其他操作入口缺 | 在所有操作入口添加密码空值检查 |
| H11 | `BackupConfig.kt` | 139-186 | `allowBackup=true` 使 ADB 备份可提取明文配置 | 设为 `false` 或加密 |
### 架构与代码质量(第二层汇总)
| # | 文件 | 行号 | 问题 | 建议 |
|---|------|------|------|------|
| H12 | `RestoreOperation.kt` | 85 | `coroutineScope``supervisorScope`,单应用失败取消全部 | 改用 `supervisorScope` |
| H13 | `PackageName` 值类 | 全局 | 多处方法签名使用 `String` 而非 `PackageName` | 统一改为 `PackageName` |
| H14 | `UserId` 值类 | 全局 | 业务层使用 `String`/`Int` 而非 `UserId` | 统一切换为 `UserId` |
| H15 | `ResticRestBridge.kt` | 246-257 | `buildV2Json` 手动拼接 JSON无转义 | 使用 `JSONObject`/`kotlinx.serialization` |
| H16 | `BackupConfig.kt` | 77-137 | 领域模型混合文件 I/O 逻辑(`fromFile`/`toFile` | 提取到 `BackupConfigSerializer` |
| H17 | `BackupOperation.kt` + `RestoreOperation.kt` | 各 300-500 行 | object 承担过多职责,混合 Shell 命令 + 数据格式 + 文件操作 | 按关注点拆分 |
| H18 | `package-list-adapter` | 33-90 | 程序化创建视图而非 XML无法热重载 | 改用 XML 布局 |
| H19 | 生产就绪 | 全局 | 大量硬编码中文字符串strings.xml 完全过时 | 全部移入 strings.xml+国际化 |
| H20 | `app/build.gradle` | 48-53 | Release 构建无混淆R8/ProGuard | 添加 `minifyEnabled true` |
| H21 | `app/build.gradle` | 41,43 | 签名密码回退为弱密码 `"android"` | 环境变量未设置时强制构建失败 |
| H22 | `.github/workflows/ci.yml` | - | CI 不运行 `test`,不验证回归 | 添加 `./gradlew test` |
| H23 | `WebdavTransport.kt` | 22-28 | 无超时配置,请求可能永远挂起 | 设置 connect/read/write 超时 |
| H24 | `SmbTransport.kt`, `WebdavTransport.kt` | 全局 | 远程操作无重试策略 | 实现指数退避重试 |
### 可维护性与无障碍(第三层汇总)
| # | 文件 | 行号 | 问题 | 建议 |
|---|------|------|------|------|
| H25 | `MD4Provider.kt` | 全文件 | 整文件死代码,被 `MissingAlgoProvider` 取代 | 删除 |
| H26 | `BackupFragment.kt` | 440-546 | 3 个流式备份方法从未被调用 | 删除或接入 |
| H27 | `RemoteTransport.kt` | 73-75 | `isFileNotFound` 扩展函数从未使用 | 删除 |
| H28 | `AppScanner.kt` | 26-33 | `DataSizes` 数据类从未被填充或读取 | 删除 |
| H29 | `PackageListAdapter.kt` | 76-88 | 卡片点击区域无障碍语义缺失 | 添加状态文字 contentDescription |
---
## 关键模式分析
### 模式 1CancellationException 被吞没(全局性)
**影响面**: 项目几乎所有协程操作通过 `RootShell.exec`,其 `catch (e: Exception)` 吞没 `CancellationException`。用户取消操作时,正在运行的 shell 命令不会收到取消信号。
**涉及文件**: `RootShell.kt:84-87`, `ResticBackup.kt:55-58,73-77,117-120,130-134`, `ResticRestore.kt:78,107`, `RootShell.kt:63-66`, `ResticWrapper.kt:315-317`
**修复**: 所有空 `catch (_: Exception)` 前加 `catch (e: CancellationException) { throw e }`
### 模式 2远程操作失败返回 Success3 处)
**涉及文件**: `SmbTransport.kt:103-109`, `WebdavTransport.kt:153-155`, `BackupOperation.kt:255-257`
**影响**: 上层调用者无法区分"操作成功"和"操作失败但返回了 Success"。
### 模式 3PackageName/UserId 值类未被方法签名采用
**涉及文件**: 全局,影响 `AppScanner`, `BackupOperation`, `RestoreOperation`, `StreamingBackup`, `PackageListAdapter`, `BackupProgress`, `RestoreProgress`
**影响**: 值类的类型安全收益完全丧失,编译器无法区分 `PackageName` 和任意 `String`
### 模式 45 个子模块重复 local/remote 分支模式
**涉及文件**: `ResticBackup.kt`, `ResticRestore.kt`, `ResticRepoInit.kt`, `ResticMaintenance.kt`, `ResticSnapshotOps.kt`
**影响**: 每个方法都复制 `if (backend == "local")` 分支,增加维护成本和出错可能。
### 模式 5BackupFragment 和 RestoreFragment 缺少 onDestroyView
**影响**: ViewPager 场景下Fragment 视图销毁后协程完成时可能操作已分离的视图。
---
## 正向发现(设计良好的实践)
| 实践 | 文件 | 说明 |
|------|------|------|
| ✅ 密码通过环境变量传递 | `ResticEnvResolver.kt` | 不在命令行中出现,防止 `ps` 窥探 |
| ✅ `shellEscape()` 一致使用 | `RootShell.kt:15` | 所有 shell 拼接参数都经过转义 |
| ✅ `execSafe()` 安全方法 | `RootShell.kt:95-101` | 提供自动参数转义的执行方法 |
| ✅ SharedFlow 用于一次性事件 | `ConfigViewModel.kt:103` | 标准实践 |
| ✅ StateFlow 用于 UI 状态 | `ConfigViewModel.kt:106` | 标准实践 |
| ✅ `repeatOnLifecycle` | `ConfigFragment.kt:75-84` | 正确使用生命周期感知收集 |
| ✅ ResticBinary 双重检查锁定 | `ResticBinary.kt:13-33` | 正确的 `@Volatile` + `synchronized` |
---
## 按文件发现密度
| 文件 | 发现数 | 最严重 |
|------|--------|--------|
| `backup/ResticRestBridge.kt` | 10+ | HIGH |
| `backup/BackupOperation.kt` | 10+ | HIGH |
| `backup/BackupConfig.kt` | 8+ | CRITICAL |
| `root/RootShell.kt` | 5+ | HIGH |
| `backup/ResticBackup.kt` | 4+ | HIGH |
| `backup/SmbTransport.kt` | 4+ | HIGH |
| `backup/WebdavTransport.kt` | 4+ | HIGH |
| `backup/RestoreOperation.kt` | 4+ | HIGH |
| `ui/PackageListAdapter.kt` | 5+ | CRITICAL (a11y) |
| `backup/ResticCommandRunner.kt` | 4+ | MEDIUM |
| `ui/ConfigViewModel.kt` | 4+ | HIGH |
| `backup/StreamingBackup.kt` | 3+ | MEDIUM |
---
## 修复路线图
### 立即修复CRITICAL
1. 密码加密存储EncryptedSharedPreferences
2. 配置文件权限加固
3. 无障碍TextView 改为语义化按钮
### 下一个版本HIGH 优先级)
4. `CancellationException` 全局修复(所有空 catch
5. ResticRestBridge 绑定 127.0.0.1 + 认证
6. tar 解压路径检查
7. SMB/WebDAV 失败时返回 Failure 而非 Success
8. `supervisorScope` 替代 `coroutineScope`
9. 死代码清理MD4Provider.kt, 3 个死方法, DataSizes 等)
10. 添加 release R8/ProGuard 混淆
11. CI 添加 `./gradlew test`
12. WebDAV 超时配置
13. 远程操作重试策略
14. 密码 UI 掩码输入
15. 多目录恢复选择
### 规划修复MEDIUM
16. `PackageName`/`UserId` 值类全面采用
17. BackupConfig 分离 I/O 逻辑
18. BackupOperation/RestoreOperation 拆分
19. PackageListAdapter 改用 XML 布局
20. 国际化:硬编码字符串移入 strings.xml
21. 释放签名密码加固
22. 前台服务通知进度更新
23. 恢复操作确认对话框
24. API 超时配置
25. `@Serializable` 死注解清理
---
## 统计概览
| 指标 | 值 |
|------|-----|
| 审查技能数 | 11 |
| 审查文件数 | 37 Kotlin + ~15 资源/配置 |
| 总发现数 | 181 |
| CRITICAL | 3 |
| HIGH | 29 |
| MEDIUM | 72 |
| LOW/INFO | 77 |
| 可删除代码 | ~150 行 + 1 个整文件 + ~20 行导入 |
| 生产就绪评分 | 58/100 |
| 测试覆盖率 | 53 测试(持续集成未运行) |

View File

@@ -0,0 +1,266 @@
# 第三阶段 — 死代码清理审查报告
> 审查范围: android-backup-gui 项目 37 个 Kotlin 源文件
> 审查技能: ecc-refactor-cleaner死代码、未使用导入、重复逻辑、废弃代码
> 已知不重复: Phase 2 已报告的 @Serializable 死注解TypeDesign F12不在此重复
> 已知不重复: memory 中 7 个待处理项不在此重复
---
## 严重程度分级
| 等级 | 含义 |
|------|------|
| 🔴 **严重** | 功能层面死代码,占用维护成本,可能引发混淆 |
| 🟠 **中** | 未使用导入/参数,可能清理但非功能阻塞 |
| 🟡 **低** | 装饰性/可清理但不影响运行 |
---
## 🔴 严重发现
### F1. `MD4Provider.kt` 整文件死代码
**文件**: `app/src/main/java/com/example/androidbackupgui/backup/MD4Provider.kt`
**行号**: 1-137整文件
**问题**: `MD4Provider``MissingAlgoProvider` 完全取代。`MissingAlgoProvider` 提供了 `MD4` + `AESCMAC` 两种算法注入,且是 `SmbTransport` 实际调用的对象。`MD4Provider` 在任何地方都未被引用。
**证据**:
- `SmbTransport` 调用的是 `MissingAlgoProvider.register()`
- 全局搜索 `MD4Provider` 仅命中自身文件
**建议**: 删除整个 `MD4Provider.kt` 文件。
---
### F2. `BackupFragment.kt` 三个死方法(流式备份未接入)
**文件**: `app/src/main/java/com/example/androidbackupgui/ui/BackupFragment.kt`
**行号**: 440-546
**问题**: 以下三个方法定义了流式备份逻辑但从未被调用:
| 方法 | 行号 | 说明 |
|------|------|------|
| `estimateBackupSize()` | 440 | 估算备份数据大小 |
| `hasEnoughSpace()` | 455 | 检查磁盘空间是否充足 |
| `runStreamingResticBackup()` | 472 | 执行流式备份FIFO 管道) |
**证据**: 全局搜索三个方法名,除自身定义外无任何调用点。`startBackup()` 方法走的是常规 restic `backup` 路径,未调用流式路径。
`runStreamingResticBackup` 上标注了 `@Suppress("UNUSED_PARAMETER")` 且参数 `outputDir: File` 从未使用,说明开发者已知此方法目前是死代码。
**建议**: 删除三个方法及相关 `import android.os.StatFs`(如果没有其他用途)。或将流式备份接入到 `startBackup` 的条件分支中。
---
### F3. `RemoteTransport.isFileNotFound()` 未使用扩展函数
**文件**: `app/src/main/java/com/example/androidbackupgui/backup/RemoteTransport.kt`
**行号**: 73-75
```kotlin
internal fun AppError.isFileNotFound(): Boolean =
this is AppError.Remote && this.isNotFound
```
**问题**: 此扩展函数定义后从未在任何地方调用。`Remote` 错误中的 `isNotFound` 字段通过 `when (error) { is AppError.Remote -> ... }` 模式匹配访问,不需要扩展函数。
**证据**: 全局搜索 `isFileNotFound` 仅命中此定义。
**建议**: 删除此扩展函数。
---
### F4. `DataSizes` 数据类及其字段从未使用
**文件**: `app/src/main/java/com/example/androidbackupgui/backup/AppScanner.kt`
**行号**: 26-33
```kotlin
@Serializable
data class DataSizes(
val apkBytes: Long = 0,
val userBytes: Long = 0,
// ...
)
```
```kotlin
data class AppInfo(
// ...
val dataSizes: DataSizes = DataSizes(), // 33 行
)
```
**问题**: `DataSizes` 类型仅用于 `AppInfo.dataSizes` 字段的默认值,没有任何代码对此字段写入非默认值或读取。这是残留的"预留"字段。
**证据**: 全局搜索 `dataSizes` 仅命中定义行33`DataSizes` 类型本身26`@Serializable` 注解也是死注解(`AppInfo` 从未被 kotlinx-serialization 序列化)。
**建议**: 删除 `DataSizes` 数据类和 `AppInfo.dataSizes` 字段。保留 `@Serializable` 的清理评估留给 Phase 2 已知报告。
---
## 🟠 中等发现
### F5. 子模块中 TAG 常量复制粘贴错误
**文件**:
- `app/src/main/java/com/example/androidbackupgui/backup/ResticRepoInit.kt` 第 7 行: `private val TAG = "ResticWrapper"`
- `app/src/main/java/com/example/androidbackupgui/backup/ResticCommandRunner.kt` 第 8 行: `private val TAG = "ResticWrapper"`
**问题**: 两个子模块使用的 TAG 为 `"ResticWrapper"`,而非自己的类名。导致 logcat 中无法区分日志来源。
**建议**: 改为 `"ResticRepoInit"``"ResticCommandRunner"`
---
### F6. 同包冗余导入(跨 7 个文件)
以下文件在 `package com.example.androidbackupgui.backup` 中,却显式 import 了同包的 `AppError``AppResult``err`
| 文件 | 冗余导入行 |
|------|-----------|
| `ResticRepoInit.kt` | `import com.example.androidbackupgui.backup.AppError/AppResult/err` |
| `ResticBackup.kt` | `import com.example.androidbackupgui.backup.AppError/AppResult/err` |
| `ResticRestore.kt` | `import com.example.androidbackupgui.backup.AppError/AppResult/err` |
| `ResticSnapshotOps.kt` | `import com.example.androidbackupgui.backup.AppError/AppResult/err` |
| `ResticMaintenance.kt` | `import com.example.androidbackupgui.backup.AppError/AppResult/err` |
| `ResticWrapper.kt` | `import com.example.androidbackupgui.backup.AppError/AppResult/err` |
| `ResticCommandRunner.kt` | `import com.example.androidbackupgui.backup.AppError`(且此导入实际未使用——该文件不引用 `AppError`|
**建议**: 清理全部冗余 import。`ResticCommandRunner.kt` 中的 `AppError` 为真正未使用导入,应删除。
---
### F7. 真正未使用的导入
| 文件 | 行号 | 导入 | 原因 |
|------|------|------|------|
| `ResticWrapper.kt` | 5 | `import kotlinx.coroutines.isActive` | 文件内无使用 |
| `ResticWrapper.kt` | 9 | `import kotlin.coroutines.coroutineContext` | 文件内无使用 |
| `BackupFragment.kt` | 34 | `import com.example.androidbackupgui.backup.formatSize` | 文件内无使用 |
| `ConfigFragment.kt` | 19-20 | `import kotlinx.coroutines.Dispatchers` / `import kotlinx.coroutines.withContext` | Fragment 类中从未使用(全部委托给 ViewModel|
| `ConfigViewModel.kt` | 8 | `import com.example.androidbackupgui.backup.formatSize` | 文件内无使用 |
**建议**: 删除上述导入。
---
### F8. 未使用参数(已标注 `@Suppress`
| 文件 | 函数 | 未使用参数 | 行号 |
|------|------|-----------|------|
| `ResticRestBridge.kt` | `handleConfig()` | `headers: Map<String, String>` | 166 |
| `StreamingBackup.kt` | `launchDataProducer()` | `userId: String` | 90 |
| `BackupFragment.kt` | `runStreamingResticBackup()` | `outputDir: File` | 475 |
**问题**: 参数被显式标记为未使用。如果近期无实现计划,应直接删除参数。
**建议**:
- `handleConfig`: `headers` 可以移除HEAD/GET/POST 都不需要它)
- `launchDataProducer`: `userId` 若留作后续多用户支持,保留但记录 TODO
- `runStreamingResticBackup`: 整个方法为死代码(见 F2删除即可
---
## 🟡 低严重度发现
### F9. `AppScanner.getAppLabel()` 方法
**文件**: `app/src/main/java/com/example/androidbackupgui/backup/AppScanner.kt`
**行号**: 87-92
```kotlin
suspend fun getAppLabel(packageName: String): String = withContext(Dispatchers.IO) {
val result = RootShell.exec("dumpsys package ...")
// ...
}
```
**问题**: 此 public 方法通过 `dumpsys package` 解析应用标签。但它返回的是包名fallback且项目中实际使用 `resolveLabels()`(通过 `PackageManager` API来获取标签。此方法未被任何代码调用。
**证据**: 项目中使用 `resolveLabels()` 获取标签,`getAppLabel()` 无调用者。
**建议**: 确认无用后删除。
---
### F10. 重复的 if-else bridge 模式(架构级别)
在 5 个子模块中(`ResticRepoInit`, `ResticBackup`, `ResticRestore`, `ResticSnapshotOps`, `ResticMaintenance`),每个方法都重复以下模式:
```kotlin
if (backend == "local") {
val env = envResolver.buildLocalEnv(...)
// run restic command
} else {
bridgeRunner.withBridge(...) { bridgeUrl ->
val env = envResolver.buildBridgeEnv(...)
// run restic command
}
}
```
**影响**: `ResticMaintenance` 中 3 个方法prune/check/stats结构完全一致仅有命令参数不同。跨模块总共 ~8 次重复。
**建议**: 可提取为公共执行函数,如 `withResticEnv(backend, ...) { env -> runner.runRestic(env, ...) }`。此为架构改进建议,非阻塞。
---
### F11. `BackupFragment.estimateBackupSize` 缩进错误
**文件**: `app/src/main/java/com/example/androidbackupgui/ui/BackupFragment.kt`
**行号**: 440-449
缩进层次错误:`val pkgEsc = ...` 等行应在 `for` 循环体内但缩进级别与函数体相同:
```kotlin
for (app in apps) {
val pkgEsc = app.packageName.value.shellEscape() // ← 缩进错误
val result = RootShell.exec(...)
```
**建议**: 修复缩进(但该函数本身是死代码 F2删除后自然解决
---
### F12. 重复的 UID 解析逻辑
**文件**:
- `AppScanner.kt``hasKeystore()`(行 111-117中解析 UID 的逻辑
- `RestoreOperation.kt``resolveAppUid()`(行 462-490中解析 UID 的逻辑
**问题**: 两处通过 `dumpsys package ... | grep 'userId='` 解析 UID 的代码逻辑高度相似。`RestoreOperation.resolveAppUid()` 更完整(支持 3 种 fallback`AppScanner.hasKeystore()` 有独立的实现。
**建议**: 可将 UID 解析提取为公共工具函数,避免两处维护。
---
## 汇总
| 编号 | 严重度 | 类别 | 位置 | 建议 |
|------|--------|------|------|------|
| F1 | 🔴 | 死代码 | `MD4Provider.kt` 整文件 | 删除 |
| F2 | 🔴 | 死代码 | `BackupFragment.kt` 440-5463 个方法)| 删除或接入 |
| F3 | 🔴 | 死代码 | `RemoteTransport.kt:73-75` | 删除扩展函数 |
| F4 | 🔴 | 死代码 | `AppScanner.kt:26-33` DataSizes | 删除 |
| F5 | 🟠 | 错误TAG | `ResticRepoInit.kt:7`, `ResticCommandRunner.kt:8` | 改为类名 |
| F6 | 🟠 | 冗余导入 | 7 个文件中的同包 import | 清理 |
| F7 | 🟠 | 未使用导入 | 5 个文件 | 删除 |
| F8 | 🟠 | 未使用参数 | 3 个函数(已 @Suppress| 删除参数或加 TODO |
| F9 | 🟡 | 死代码 | `AppScanner.kt:87-92` getAppLabel | 确认后删除 |
| F10 | 🟡 | 重复模式 | 5 个子模块中的 if-else bridge | 提取公共执行函数 |
| F11 | 🟡 | 格式问题 | `BackupFragment.kt:440-449` 缩进 | 修复(随 F2 解决)|
| F12 | 🟡 | 重复逻辑 | UID 解析在两处重复 | 提取工具函数 |
---
## 清理收益估算
- 可删除文件: 1 个(`MD4Provider.kt`, ~5.1KB
- 可删除代码行: ~150 行(死方法 + DataSizes + 扩展函数)
- 可清理导入: ~20 行(冗余 + 未使用导入)
- 可清理参数: 3 个
- 代码库缩减: ~6-8% 的源代码量

View File

@@ -0,0 +1,561 @@
# Android Backup GUI — OWASP 导向安全审查报告
> 审查日期: 2026-06-06
> 范围: 全部 37 个 Kotlin 源文件 + AndroidManifest.xml
> 已知问题已排除memory 中记录的 7 项 Remaining Gaps 不在此报告重复)
---
## 目录
1. [认证与授权](#1-认证与授权)
2. [输入校验](#2-输入校验)
3. [敏感数据处理](#3-敏感数据处理)
4. [API 安全](#4-api-安全)
5. [安全配置](#5-安全配置)
6. [日志/调试信息泄露](#6-日志调试信息泄露)
7. [Intent/组件暴露](#7-intent组件暴露)
---
## 1. 认证与授权
### 1.1 无权限检查直接执行 Root 命令
**严重程度**: 中
**文件**: `backup/BackupOperation.kt`
**位置**: 第 109、173、228、246-249、273-276、297-300、308-311、334、350、369 行等
`RootShell.exec()` 在整个代码库中被广泛调用,但在调用前不做任何权限检查。虽然没有运行时安全检查(因为是 root 应用),但以下操作直接通过 `RootShell.exec()` 执行系统命令并拼接用户控制的输入:
```kotlin
// BackupOperation.kt:109 — cp 命令使用 shellEscape
RootShell.exec("cp '${apkPath.shellEscape()}' ...")
// BackupOperation.kt:297 — tar 命令拼接目录名
RootShell.exec("set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} ...")
// BackupOperation.kt:333 — 读取含有应用名的系统文件
val result = RootShell.exec("cat '$ssaidFile' 2>/dev/null")
```
**修复建议**: 虽然 `shellEscape()` 提供了防御,但所有 root shell 调用应使用 `execSafe()` 而不是 `exec()`
### 1.2 RootShell 启用 libsu 详细日志
**严重程度**: 低
**文件**: `root/RootShell.kt`
**位置**: 第 54 行
```kotlin
Shell.enableVerboseLogging = true
```
生产环境中启用 libsu 的详细日志,会将所有 su 会话操作的细节写入 logcat。
**修复建议**: 改为构建标志控制,仅在 debug 构建启用。
### 1.3 QUERY_ALL_PACKAGES 敏感权限
**严重程度**: 低(已声明为必要)
**文件**: `app/src/main/AndroidManifest.xml`
**位置**: 第 7 行
```xml
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
```
Google Play 对 `QUERY_ALL_PACKAGES` 有严格审核要求,该应用的核心功能需要此权限以列举用户安装的应用。
**修复建议**: 确认应用不上架 Google Play 或已通过审核。当前无修复必要。
---
## 2. 输入校验
### 2.1 Restic 密码为空时仍继续执行
**严重程度**: 高
**文件**: `ui/ConfigViewModel.kt`
**位置**: 第 180-183 行
```kotlin
if (form.repo.isEmpty() || form.password.isEmpty()) {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "请填写仓库路径和密码")) }
return
}
```
`initResticRepo()``form.password.isEmpty()` 时返回。但 `refreshResticStatus()`(第 217-256 行)和 `showResticStats()`(第 258-295 行)和 `pruneResticSnapshots()`(第 297-344 行)在 `form.password` 为空时不会检查,直接将空密码传给 `ResticWrapper`
**修复建议**: 在所有操作入口添加密码空值检查,或至少记录 warning。
### 2.2 用户配置字段无输入校验
**严重程度**: 中
**文件**: `backup/BackupConfig.kt`
**位置**: 第 78-136 行 (`fromFile`)
配置解析使用 `toIntOrNull()` 处理整数(静默回退到默认值),字符串字段没有任何长度、格式或内容验证。例如:
- `resticBackendUrl` 不验证是否为合法 URL
- `resticBackendShare` 不验证 SMB share 名称格式
- `resticBackendUser``resticBackendPass` 不验证为空时的行为
**文件**: `ui/ConfigFragment.kt`
**位置**: 第 200-217 行 (`saveConfig`)
```kotlin
resticPassword = binding.resticPasswordEdit.text?.toString() ?: "",
resticBackendUrl = binding.resticBackendUrlEdit.text?.toString()?.trim() ?: "",
```
来自 UI 的输入仅进行了简单的 null→empty 转换,没有任何格式校验。
**修复建议**: 添加输入验证层,至少检查 URL 格式、必填字段非空。对于 restic 仓库密码,提示用户确认。
### 2.3 ResticRestBridge URI 路径注入风险
**严重程度**: 中
**文件**: `backup/ResticRestBridge.kt`
**位置**: 第 62-117 行 (`handleRequest`)
URI 路径解析时,`segments``strippedPath.split("/").filter { it.isNotEmpty() }` 产生,然后直接用于构建远程路径:
```kotlin
// 第 100-102 行
val type = firstSegment
val name = if (segments.size >= 2) segments.drop(1).joinToString("/") else null
```
以及后续的远程路径构建:
```kotlin
// 第 232 行
val remoteDir = "$remoteBase/$type"
// 第 262 行
val remotePath = "$remoteBase/$type/$name"
```
虽然 restic 是唯一客户端,但 URI 中的编码路径可能被滥用于路径遍历。`name` 通过 `joinToString("/")` 直接拼接到远程路径。
**修复建议**: 对 `type``name` 进行路径字符过滤,拒绝 `..``./` 等特殊路径序列。添加到 `RemoteTransport` 调用前。
---
## 3. 敏感数据处理
### 3.1 Restic 密码和凭据明文存储
**严重程度**: 高
**文件**: `backup/BackupConfig.kt`
**位置**: 第 69、73 行
```kotlin
val resticPassword: String = "",
val resticBackendPass: String = "",
```
**文件**: `backup/BackupConfig.kt`
**位置**: 第 139-186 行 (`toFile`)
```kotlin
appendLine("restic_password=\"${config.resticPassword}\"")
appendLine("restic_backend_pass=\"${config.resticBackendPass}\"")
```
所有密码以明文写入配置文件 `backup_settings.conf`,存储在 `filesDir``/data/data/com.example.androidbackupgui/files/`)。在已有 root 权限的设备上,其他 root 进程可以读取该文件。Android `android:allowBackup="true"` 更使 ADB 备份可以提取此文件。
**修复建议**:
- 使用 `EncryptedSharedPreferences`AndroidX Security加密存储密码
- 或在运行时从用户输入获取密码,不持久化到磁盘
-`allowBackup` 设为 `false` 以防止 ADB 备份提取
### 3.2 SSAID 唯一标识符泄露
**严重程度**: 中
**文件**: `backup/BackupOperation.kt`
**位置**: 第 331-347 行 (`backupSsaid`)
```kotlin
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) {
File(appDir, "ssaid.txt").writeText(value) // 明文写入备份输出
}
```
SSAIDSettings Secure Android ID是每个应用的唯一标识符属于 `Settings.Secure` 级别的敏感标识符。备份文件中的 `ssaid.txt` 以明文存储,且:
**文件**: `backup/LogUtil.kt` 间接受到影响(日志中可能包含 SSAID
实际上没有日志泄露,但 `ssaid.txt` 作为备份的一部分进入 restic 仓库restic 仓库本身加密但元数据路径可见。
**修复建议**: SSAID 备份/恢复是 restore 功能的核心需求,当前处理方式可接受。但应在文档中说明此行为。
### 3.3 WiFi 配置包含网络密码
**严重程度**: 中
**文件**: `backup/WifiManager.kt`
**位置**: 第 41-47 行 (`backup`)
```kotlin
val result = RootShell.exec("cp '$wifiSource' '${wifiDest.absolutePath.shellEscape()}'")
```
WiFi 配置文件(`WifiConfigStore.xml``wpa_supplicant.conf`)包含网络 SSID 和密码的明文或哈希值。这些文件被复制到备份输出,进而可能被 restic 快照处理。
**修复建议**: 在备份 WiFi 配置时过滤或加密敏感字段。WiFi 密码至少应标记为需要额外保护。
### 3.4 Restic 密码通过环境变量传递
**严重程度**: 中性(设计合理)
**文件**: `backup/ResticEnvResolver.kt`
**位置**: 第 17、35 行
```kotlin
env["RESTIC_PASSWORD"] = password
```
通过环境变量而非命令行参数传递密码是**正确的做法**,可以防止密码被 `ps` 等进程列表工具窥探。这是值得保持的好设计。
**注意**: 环境变量仍可被 `/proc/self/environ` 读取(在 root 权限下),但对于该应用的威胁模型(已有 root 权限),这是可接受的。
---
## 4. API 安全
### 4.1 ResticRestBridge 无认证监听本地端口
**严重程度**: 高
**文件**: `backup/ResticRestBridge.kt`
**位置**: 第 22-27 行
```kotlin
class ResticRestBridge(...) : NanoHTTPD(0) {
```
`NanoHTTPD(0)` 默认绑定到 `0.0.0.0`所有网络接口端口由系统分配0 表示任意可用端口)。桥接器不包含任何认证机制:
- 第 36-54 行 (`serve`): 没有 IP 过滤、Token 检查或任何认证
- 第 62-117 行 (`handleRequest`): 直接处理所有 HTTP 方法GET/POST/DELETE/HEAD
- 第 348-371 行 (`handlePostBlob`): 接受任意文件上传到远程存储
- 第 376-386 行 (`handleDeleteBlob`): 允许删除远程存储中的任意 blob
**文件**: `backup/RestBridgeRunner.kt`
**位置**: 第 76 行
```kotlin
val bridgeUrl = "rest:http://127.0.0.1:$port/$repoPath"
```
虽然 restic 客户端被指示连接到 `127.0.0.1`,但 NanoHTTPD 服务器绑定在 `0.0.0.0`。同一局域网/WLAN 下的其他设备可以访问此端口。
**修复建议**: 创建 NanoHTTPD 时指定只监听 127.0.0.1。NanoHTTPD 构造函数的端口参数后可以添加 IP 地址参数,或使用 `NanoHTTPD("127.0.0.1", 0)`(如果 API 支持)。否则,在启动后添加 iptables 规则限制本地访问。
### 4.2 ResticRestBridge 错误信息泄露
**严重程度**: 低
**文件**: `backup/ResticRestBridge.kt`
**位置**: 第 47-51 行
```kotlin
} catch (e: Exception) {
Log.e(TAG, "request failed: $method $uri", e)
newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain",
e.message ?: "Internal error"
)
}
```
异常消息直接返回给 HTTP 客户端。更严重的是,`streamBodyToFile` 的失败也返回给客户端:
```kotlin
// 第 207-210 行
if (tmpResult.isFailure) return@runBlocking newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain",
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}"
)
```
**修复建议**: 将详细的错误消息仅记录到日志,返回通用的 "Internal error"。
---
## 5. 安全配置
### 5.1 allowBackup 启用
**严重程度**: 高
**文件**: `app/src/main/AndroidManifest.xml`
**位置**: 第 13 行
```xml
android:allowBackup="true"
```
`allowBackup="true"` 允许通过 `adb backup` 提取应用的全部私有数据,包括 `filesDir` 中的 `backup_settings.conf`(包含明文 restic 密码和备份凭据)。
**修复建议**: 设置为 `false`
```xml
android:allowBackup="false"
android:fullBackupContent="false"
```
### 5.2 无网络安全配置
**严重程度**: 中
**文件**: `app/src/main/AndroidManifest.xml`
**位置**: 第 12-18 行
应用声明了 `INTERNET``ACCESS_NETWORK_STATE` 权限,支持 WebDAV、SMB 和 rest-server 远程传输,但未配置 `android:networkSecurityConfig`。这意味着默认允许所有未加密的明文流量HTTP对于传输备份数据的场景存在安全风险。
**修复建议**: 添加 `res/xml/network_security_config.xml` 网络安全配置,明确允许/限制明文流量目标。如果仅使用内网 NAS可以限制明文到特定内网网段。
### 5.3 无备份数据加密说明
**严重程度**: 低
备份的数据(应用 APK、数据目录、WiFi 配置等不进行应用层加密。restic 仓库会进行传输中和静态加密(如果配置了),但本地 staging 目录中的备份文件放在外部存储的明文目录中。
**修复建议**: 建议用户在文档中了解本地备份目录中的文件未加密restic 仓库提供加密但需正确保管密码。
---
## 6. 日志/调试信息泄露
### 6.1 RootShell 命令日志泄露
**严重程度**: 中
**文件**: `root/RootShell.kt`
**位置**: 第 82、85 行
```kotlin
Log.w(TAG, "exec timeout (${timeoutMs}ms): $command")
Log.e(TAG, "exec failed: $command", e)
```
`RootShell.exec()` 在命令失败或超时时将完整的命令字符串记录到 logcat。如果 `exec()` 被传入包含密码或 token 的命令,这些敏感数据会被泄露到 logcat。
当前实现中 `BackupOperation.kt` 主要使用 `execSafe()`(通过 `shellEscape()`),但 `exec()` 是公有函数,任何调用者都可能传入未脱敏的命令。
**修复建议**:
- 在日志中截断或脱敏命令字符串
- 或更严格地——不在日志中包含命令内容,只记录标签和错误码
### 6.2 SSAID 值记录到日志
**严重程度**: 高
**文件**: `backup/BackupOperation.kt`
**位置**: 第 345 行
```kotlin
Log.d(TAG, "backupSsaid: backed up SSAID for $packageName = $value")
```
SSAIDSettings Secure Android ID是每个应用唯一的设备级标识符直接以明文记录到 logcat。logcat 在 Android 8+ 受权限保护,但仍可被系统应用和 adb 读取。
**文件**: `backup/RestoreOperation.kt`
**位置**: 第 398、401、411 行
```kotlin
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via XML (uid=$uid)")
Log.w(TAG, "restoreSsaid: XML edit completed but entry not found, falling back")
Log.e(TAG, "restoreSsaid: failed to set SSAID for $packageName: ${result.error}")
```
虽然恢复端未直接记录 SSAID 值,但记录了 UID唯一整数标识符结合包名可识别设备。
**修复建议**: 不在日志中记录 SSAID 值,只记录操作状态。
### 6.3 LogUtil 日志文件可能包含敏感信息
**严重程度**: 中
**文件**: `backup/LogUtil.kt`
**位置**: 第 45-58 行 (`writeLog`)
```kotlin
private fun writeLog(level: String, tag: String, message: String) {
val dir = baseDir ?: return
executor.execute {
...
val line = "$timestamp $level/$tag: $message\n"
logFile.appendText(line)
}
}
```
`LogUtil` 将所有 `i/w/e` 日志写入 `baseDir/logs/` 目录下的日期文件。这些日志文件包含 `LogUtil.i/w/e()` 调用的全部消息,可能包括命令参数、错误详情等敏感信息。日志文件保留 7 天。
```kotlin
// 第 77-84 行
fun getLogFiles(): List<File> {
val logDir = File(dir, "logs")
return logDir.listFiles()
?.filter { it.name.endsWith(".log") }
?.sortedBy { it.name } ?: emptyList()
}
```
日志文件可通过 `getLogFiles()` 获取,虽然当前没有代码直接暴露给其他应用,但 restic 备份会扫描此目录,导致日志被包含在备份快照中。
**修复建议**:
- 添加日志级别过滤,不在文件日志中包含 `Log.d` 级别的调试信息
- 考虑在日志过虑器中脱敏已知的敏感模式密码、SSAID、token
- 将日志目录添加到 restic 备份排除列表
### 6.4 配置 URL 日志可能包含内嵌凭据
**严重程度**: 低
**文件**: `ui/ConfigViewModel.kt`
**位置**: 第 178 行
```kotlin
Log.i(TAG, "initResticRepo: repo=${form.repo} backend=${form.backend} url=${form.backendUrl}")
```
如果用户将凭据嵌入 backend URL`https://user:password@host/path`这些凭据会被记录到日志。WebDAV URL 有时包含用户名。
**修复建议**: 在日志中脱敏 URL 中的用户信息部分。
### 6.5 Shell 命令冗余日志
**严重程度**: 低
**文件**: `backup/ResticCommandRunner.kt`
**位置**: 第 36、42、76-77 行等
```kotlin
Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args -> cmd=$cmd")
Log.i(TAG, "runRestic cmd=${cmdArgs.joinToString(" ")}")
Log.i(TAG, "runRestic exitCode=$exitCode stdout_len=${stdout.length}")
if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText.trim()}")
```
尽管密码通过环境变量而非命令行参数传递(正确做法),但命令参数被完整记录。在 restic `init``backup``restore` 等命令中,命令行包含仓库路径、标签、主机名等信息,这些信息本身通常不敏感,但 `args` 参数在日志中可见。
文件路径日志(第 173 行):
```kotlin
Log.i(TAG, "runResticWithStdin cmd=${cmdArgs.joinToString(" ")} stdin=${stdinFile.absolutePath}")
```
**修复建议**: 当前日志设计合理——密码不在命令行中,因此日志不包含密码。无需更改。
---
## 7. Intent/组件暴露
### 7.1 ResticRestBridge 绑定到 0.0.0.0
**严重程度**: 高
(已在 4.1 中详述——此问题跨类别)
**文件**: `backup/ResticRestBridge.kt`
**位置**: 第 27 行
```kotlin
) : NanoHTTPD(0) {
```
NanoHTTPD 默认绑定所有网络接口。同一设备上或同一网络中的恶意应用/用户可访问此 REST 接口,读取/写入远程存储中的 blob 数据。
### 7.2 BackupService 未导出但使用隐式 Intent
**严重程度**: 低
**文件**: `backup/BackupService.kt`
**位置**: 第 21-23 行
```kotlin
const val ACTION_START_BACKUP = "com.example.androidbackupgui.action.START_BACKUP"
const val ACTION_STOP_BACKUP = "com.example.androidbackupgui.action.STOP_BACKUP"
const val EXTRA_STATUS_TEXT = "status_text"
```
**文件**: `ui/BackupFragment.kt`
**位置**: 第 190-192、391-394 行
```kotlin
val serviceIntent = Intent(requireContext(), BackupService::class.java)
serviceIntent.action = BackupService.ACTION_START_BACKUP
```
Service 声明为 `exported="false"`所以只有同一应用内可访问——安全。Action 字符串使用完整包名前缀,避免了与其他应用的 Intent 冲突。
### 7.3 MainActivity 导出为 LAUNCHER
**严重程度**: 低(标准做法)
**文件**: `app/src/main/AndroidManifest.xml`
**位置**: 第 20-27 行
```xml
<activity android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
```
标准 LAUNCHER Activity 导出设置,但在 root 应用上下文中,其他应用可以调用此 Activity 触发初始化和权限请求流程。
**修复建议**: 对于意外启动,可添加 `android:exported="true"` 但仅保留 MAIN/LAUNCHER intent-filter。当前配置已正确。
---
## 问题严重程度汇总
|编号|严重程度|类型|文件|行号|
|---|---|---|---|---|
|3.1|**高**|敏感数据-明文密码|BackupConfig.kt|69,73,178-182|
|5.1|**高**|安全配置-allowBackup|AndroidManifest.xml|13|
|4.1 / 7.1|**高**|API 安全-无认证桥接|ResticRestBridge.kt|27,36-54|
|6.2|**高**|日志泄露-SSAID|BackupOperation.kt|345|
|2.1|中|输入校验-密码空值检查缺失|ConfigViewModel.kt|217-256|
|2.2|中|输入校验-字段无格式验证|BackupConfig.kt, ConfigFragment.kt|78-136,200-217|
|2.3|中|输入校验-路径注入风险|ResticRestBridge.kt|62-117|
|3.2|中|敏感数据-SSAID 明文备份|BackupOperation.kt|331-347|
|3.3|中|敏感数据-WiFi 配置含密码|WifiManager.kt|41-47|
|5.2|中|安全配置-无 networkSecurityConfig|AndroidManifest.xml|12-18|
|6.1|中|日志泄露-命令内容|RootShell.kt|82,85|
|6.3|中|日志泄露-文件日志含敏感信息|LogUtil.kt|45-58|
|1.1|低|授权-无权限检查模式|BackupOperation.kt|多处|
|1.2|低|配置-冗余 libsu 日志|RootShell.kt|54|
|4.2|低|API-错误信息泄露|ResticRestBridge.kt|47-51,207-210|
|6.4|低|日志泄露-URL 可能含凭据|ConfigViewModel.kt|178|
---
## 最重要的修复建议(按优先级排序)
1. **(紧急)修复 ResticRestBridge 绑定到 0.0.0.0** — 改为仅监听 127.0.0.1,防止局域网内其他设备访问 REST 桥接 API。
2. **(紧急)设置 allowBackup="false"** — 防止 ADB 备份提取明文密码配置文件。
3. **(高优先级)移除 SSAID 值日志输出** — `BackupOperation.kt:345` 中删除 `= $value` 部分。
4. **(高优先级)对备份配置使用加密存储** — 使用 `EncryptedSharedPreferences` 或运行时密码输入,避免密码明文持久化。
5. **(中优先级)添加输入验证层** — 对 `resticBackendUrl` 等字段进行格式验证,所有操作前检查密码非空。
6. **(中优先级)添加 networkSecurityConfig** — 限制明文流量目标。
7. **(中优先级)审查 LogUtil 日志内容** — 确保日志文件中不包含密码/SSAID 等敏感字段。

77
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,77 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %OS%==Windows_NT setlocal
:omega

149
ktlint.py Executable file
View File

@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Kotlin LSP client for code diagnostics. Collects all LSP messages."""
import subprocess, json, sys, os, signal, time
from pathlib import Path
def run_diagnostics(project_dir: str, file_path: str, timeout: int = 60):
proc = subprocess.Popen(
['/usr/local/bin/kotlin-language-server'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
cwd=project_dir, preexec_fn=lambda: signal.signal(signal.SIGPIPE, signal.SIG_DFL)
)
def send(msg):
data = json.dumps(msg).encode('utf-8')
proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode('utf-8'))
proc.stdin.write(data)
proc.stdin.flush()
def recv(timeout_s=5):
content_length = 0
end = time.time() + timeout_s
while time.time() < end:
if proc.poll() is not None:
return None
ready = proc.stdout.readable()
if not ready:
time.sleep(0.05)
continue
line = proc.stdout.readline()
if not line:
time.sleep(0.05)
continue
line = line.decode('utf-8', errors='replace').strip()
if line.startswith('Content-Length:'):
content_length = int(line.split(':')[1].strip())
elif line == '' and content_length > 0:
body = proc.stdout.read(content_length).decode('utf-8', errors='replace')
return json.loads(body)
return 'TIMEOUT'
all_msgs = []
send({
'jsonrpc': '2.0', 'id': 1, 'method': 'initialize',
'params': {
'processId': os.getpid(),
'capabilities': {
'textDocument': {'diagnostics': {'dynamicRegistration': False}},
'workspace': {'didChangeWatchedFiles': {'dynamicRegistration': False}}
},
'rootUri': f'file://{project_dir}',
'workspaceFolders': [{'uri': f'file://{project_dir}', 'name': Path(project_dir).name}]
}
})
# Read all messages until we get initialize result
end = time.time() + timeout
init_ok = False
while time.time() < end and not init_ok:
msg = recv(5)
if msg is None:
break
if msg == 'TIMEOUT':
continue
all_msgs.append(('init', msg))
if msg.get('id') == 1 and 'result' in msg:
init_ok = True
if not init_ok:
return all_msgs, f'INIT_TIMEOUT after {timeout}s'
send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}})
# Open file
file_uri = f'file://{file_path}'
with open(file_path) as f:
content = f.read()
send({
'jsonrpc': '2.0', 'method': 'textDocument/didOpen',
'params': {
'textDocument': {
'uri': file_uri, 'languageId': 'kotlin',
'version': 1, 'text': content
}
}
})
# Collect messages for remaining time
end = time.time() + 30
while time.time() < end:
msg = recv(3)
if msg is None or msg == 'TIMEOUT':
continue
all_msgs.append(('open', msg))
# Shutdown
send({'jsonrpc': '2.0', 'id': 2, 'method': 'shutdown', 'params': {}})
try:
proc.terminate()
proc.wait(timeout=3)
except:
proc.kill()
return all_msgs, 'OK'
if __name__ == '__main__':
file_path = os.path.abspath(sys.argv[1])
project_dir = os.path.abspath(sys.argv[2]) if len(sys.argv) > 2 else os.getcwd()
print(f'Project: {project_dir}')
print(f'File: {file_path}\n')
msgs, status = run_diagnostics(project_dir, file_path)
print(f'Status: {status}')
print(f'Messages received: {len(msgs)}\n')
diag_count = 0
for phase, msg in msgs:
method = msg.get('method', '?')
if 'id' in msg:
method = f'response(id={msg["id"]})'
if 'error' in msg:
print(f' [{phase}] {method} ERROR: {msg["error"]}')
elif method == 'window/logMessage':
print(f' [{phase}] log: {msg.get("params",{}).get("message","")}')
elif method == 'window/showMessage':
print(f' [{phase}] show: {msg.get("params",{}).get("message","")}')
elif method == 'textDocument/publishDiagnostics':
diags = msg.get('params', {}).get('diagnostics', [])
diag_count += len(diags)
uri = msg.get('params', {}).get('uri', '')
print(f' [{phase}] publishDiagnostics ({len(diags)} items): {os.path.basename(uri)}')
for d in diags:
r = d.get('range', {})
s = r.get('start', {})
sev = {1:'E',2:'W',3:'I',4:'H'}.get(d.get('severity'),'?')
print(f' {sev} {s.get("line",0)+1}:{s.get("character",0)+1} {d.get("message","")}')
elif method.startswith('response'):
if 'result' in msg:
caps = msg.get('result', {}).get('capabilities', {})
print(f' [{phase}] {method} capabilities: {json.dumps(caps, indent=2)[:400]}')
else:
print(f' [{phase}] {method}')
else:
print(f' [{phase}] {method}: {json.dumps(msg, indent=2)[:200]}')
print(f'\nTotal diagnostics: {diag_count}')

333
security-review-report.md Normal file
View File

@@ -0,0 +1,333 @@
# Android Backup GUI — 安全审查报告
**审查日期**: 2026-06-06
**审查范围**: 37 个 Kotlin 源文件
**审查技能**: 安全漏洞检测注入、Secret 泄露、权限滥用、路径遍历)
---
## 严重程度分级说明
| 等级 | 定义 |
|------|------|
| CRITICAL | 可直接导致 root 提权、用户数据泄露或远程命令执行的漏洞。必须立即修复。 |
| HIGH | 在特定条件下可导致敏感数据泄露或越权访问。应在下一版本修复。 |
| MEDIUM | 安全风险较低,或需要复杂攻击链才能利用。建议规划修复。 |
| LOW | 信息泄露风险极低,或设计上可接受但不够理想。可选修复。 |
---
## 发现汇总
| # | 严重程度 | 类别 | 文件 | 行号 |
|---|----------|------|------|------|
| 1 | **CRITICAL** | Secret 泄露 | `BackupConfig.kt` | 69, 73, toFile() |
| 2 | **CRITICAL** | Secret 泄露 | `BackupConfig.kt` | toFile() |
| 3 | **HIGH** | 认证缺失 | `ResticRestBridge.kt` | 27 |
| 4 | **HIGH** | 路径遍历/越权写入 | `RestoreOperation.kt` | restoreData() |
| 5 | **MEDIUM** | 命令注入(Sed) | `RestoreOperation.kt` | 250-253 |
| 6 | **MEDIUM** | 信息泄露(Logcat) | `RootShell.kt` | 55 |
| 7 | **MEDIUM** | 路径遍历 | `ResticRestBridge.kt` | 246-257 |
| 8 | **MEDIUM** | 信息泄露(Logcat) | `ResticCommandRunner.kt` | 40-41 |
| 9 | **MEDIUM** | 加密/安全存储 | `BackupConfig.kt` | 68-73 |
| 10 | **LOW** | 缺少参数验证 | `AppScanner.kt` | 多处 |
| 11 | **LOW** | SMB 签名关闭 | `SmbTransport.kt` | 26 |
| 12 | **LOW** | 证书固定缺失 | `WebdavTransport.kt` | 22-28 |
---
## 详细发现
### 🔴 CRITICAL: 凭据明文存储在配置文件中
**文件**: `BackupConfig.kt` 第 69 行
**文件**: `BackupConfig.kt` 第 73 行
**文件**: `BackupConfig.kt` toFile() 方法
```kotlin
// 第 69 行
val resticPassword: String = "",
// 第 73 行
val resticBackendPass: String = "",
```
**问题**: Restic 仓库密码、SMB/WebDAV 密码以明文形式存储在 `backup_settings.conf` 文件中。配置文件位于 `filesDir/backup_settings.conf`,在 root 权限下对任何进程可读。`toFile()` 方法(~第 156-157 行)将密码直接写入文件:
```kotlin
appendLine("restic_password=\"${config.resticPassword}\"")
appendLine("restic_backend_pass=\"${config.resticBackendPass}\"")
```
此外UI 中密码以明文显示和编辑(`ConfigFragment.kt` 第 151 行)。
**风险**: 任何具有 root 权限的进程(或通过漏洞获得 root 的恶意应用)可读取这些凭据。如果用户使用相同的 restic 密码保护多个设备,泄露范围会扩大。
**建议**:
1. 使用 Android `EncryptedSharedPreferences` 存储密码(加密后存储在配置目录)
2. 密码字段在 UI 中使用 `inputType="textPassword"` 隐藏显示
3. 考虑使用 Android Keystore 进行密钥管理
4. 配置文件设置为仅 app 自身可读(`MODE_PRIVATE`,但 root 环境下效果有限)
---
### 🔴 CRITICAL: 配置文件写入默认权限不安全
**文件**: `BackupConfig.kt``toFile()` 方法(~第 144 行)
```kotlin
fun toFile(config: BackupConfig, file: File) {
file.parentFile?.mkdirs()
file.writeText(buildString { ... })
}
```
**问题**: `file.writeText()` 使用系统默认文件权限。在 Android 上,`filesDir` 中的文件默认模式为 `MODE_PRIVATE`,但 root 权限环境绕过此保护。此外没有任何文件权限的显式设置。
**建议**: 保存配置文件后显式设置权限:
```kotlin
file.setReadable(true, true) // owner-only readable
file.setWritable(true, true) // owner-only writable
```
考虑迁移到 Android KeyStore + EncryptedSharedPreferences。
---
### 🔴 HIGH: ResticRestBridge 绑定到所有网络接口且无认证
**文件**: `ResticRestBridge.kt` 第 27 行
```kotlin
class ResticRestBridge(...) : NanoHTTPD(0) {
```
**问题**: `NanoHTTPD(0)` 绑定到 `0.0.0.0`(所有网络接口),随机端口。而桥接 URL 使用的是 `127.0.0.1``RestBridgeRunner.kt` 第 72 行),但服务器本身对所有接口开放。该桥接提供无需任何认证的完整备份仓库读写访问(`GET`/`POST`/`DELETE` blob、`HEAD` 检查、`list` 操作)。
**风险**: 设备上任何进程(不需要 root都可以扫描开放端口、连接到桥接并读取或写入备份仓库。由于桥接在随机端口上运行且生命周期短暂利用难度稍高但仍存在。
**建议**:
1. 使用 `ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"))` 或 NanoHTTPD 的 `bindAddr` 参数显式绑定到 localhost
2. 添加认证令牌restic REST API 支持 token 认证)
3. 限制响应时间窗口,使用后立即删除 blob
---
### 🔴 HIGH: Tar 解压使用 `-C /` 可能导致系统文件覆写
**文件**: `RestoreOperation.kt``restoreData()` 方法(~第 137-149 行)
```kotlin
val baseCmd = when {
archive.name.endsWith(".zst") ->
"set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null"
...
}
```
**问题**: 备份存档使用根目录 `/` 解压。`isArchiveSafe` 方法(~第 220-232 行)仅检查 `..` 路径穿越和指向外部的符号链接,但**不检查**
- 存档中的绝对路径条目(如 `/etc/passwd``/system/bin/app_process`
- 硬链接(可绕过 `..` 检查)
- 设备节点
- 解压总量(可用于磁盘空间耗尽攻击)
如果攻击者能够修改备份文件(例如通过恶意 App 访问外部存储),解压操作可覆写任意系统文件。
**建议**:
1. 添加对绝对路径的检查 —— 拒绝包含 `/` 前缀路径(绝对路径)的存档
2. 使用 `isArchiveSafe` 补充绝对路径检测:`line.startsWith("/")`
3. 考虑使用 `--strip-components` 选项或在临时目录解压后再移动到目标路径
4. 添加存档大小和解压条目数量上限
---
### 🟡 MEDIUM: SSAID 恢复中的 Sed 命令注入风险
**文件**: `RestoreOperation.kt` 第 250-253 行
```kotlin
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'")
}
```
**问题**: `ssaidValue``packageName` 虽然经过了 `shellEscape()`(处理单引号),但 Sed 模式中使用 `#` 作为分隔符。如果 `ssaidValue` 包含 `#`UUID 不可能,但从文件读取的 SSAID 可能包含任意字符),会破坏 Sed 命令结构。此外,`shellEscape()` 只处理 shell 层的单引号,不处理 Sed 层的 `\``&``/` 等特殊字符。
**风险**: 若攻击者可通过修改 `ssaid.txt` 文件插入恶意 Sed 表达式,可能导致任意文件写入。
**建议**:
1. 使用纯 Kotlin XML 解析(如 `XmlPullParser`)操作 `settings_ssaid.xml`,而不是 Sed
2. 或使用 `sed -e` 的分隔符参数引用,并验证 `ssaidValue` 只包含十六进制字符
---
### 🟡 MEDIUM: RootShell 启用了 libsu 详细日志
**文件**: `RootShell.kt` 第 55 行
```kotlin
Shell.enableVerboseLogging = true
```
**问题**: libsu 的详细日志会将所有 shell 命令输出到 Logcat。Logcat 在 Android 上对任何具有 `READ_LOGS` 权限的应用可读。这可能导致命令路径、参数、错误消息等信息泄露。
**风险**: 调试期间有助于开发,但生产版本应禁用。命令本身不包含密码(通过环境变量传递),但路径结构和目录名可能暴露敏感信息。
**建议**:
1. 根据构建类型控制日志级别:
```kotlin
Shell.enableVerboseLogging = BuildConfig.DEBUG
```
2. 或完全移除该行
---
### 🟡 MEDIUM: ResticRestBridge JSON 手动拼接存在注入风险
**文件**: `ResticRestBridge.kt` 第 246-257 行
```kotlin
private fun buildV2Json(items: List<RemoteTransport.RemoteFileInfo>): String {
val sb = StringBuilder("[")
var first = true
for (item in items) {
...
sb.append("{\"name\":\"${item.name}\",\"size\":${item.size}}")
}
sb.append("]")
return sb.toString()
}
```
**问题**: 文件名 `item.name` 直接插值到 JSON 字符串中。若远程存储上的文件名包含 `"``\\``\n` 等字符,会破坏 JSON 结构,可能导致解析错误或意外的数据暴露。
**风险**: 文件名来自远程存储SMB/WebDAV攻击者可能控制这些名称。返回给 restic 的损坏 JSON 可能导致备份操作失败或状态误报。
**建议**: 使用 `kotlinx-serialization``JSONArray` 构建 JSON。
---
### 🟡 MEDIUM: ResticCommandRunner 日志暴露仓库 URL
**文件**: `ResticCommandRunner.kt` 第 40-41 行
```kotlin
Log.d(TAG, "runRestic REPOSITORY=${env["RESTIC_REPOSITORY"]}")
```
**问题**: 虽然代码注释正确指出 `RESTIC_PASSWORD` 不应记录,`RESTIC_REPOSITORY` 仍可能包含 SMB 共享名称、仓库路径等敏感信息。Logcat 可被其他应用读取。
**建议**: 至少将敏感部分截断或哈希,或仅在 DEBUG 构建下记录。
---
### 🟡 MEDIUM: 多个凭据未加密存储在内存中
**文件**: `BackupConfig.kt` 第 68-73 行
```kotlin
val resticPassword: String = "",
val resticBackendUser: String = "",
val resticBackendPass: String = "",
```
**问题**: `BackupConfig` 作为 `@Serializable data class`,所有密码字段在进程生命周期内以不可变字符串形式保存在内存中。字符串在 Java 中不可变,无法显式清除(零覆盖)。
此外,`ResticWrapper` 的所有公开 API 方法都将密码作为方法参数传递,导致 Activity/Fragment/ViewModel 中密码的副本散布各处。
**建议**:
1. 通过值对象传递密码,操作完成后立即清除
2. 考虑使用 `CharArray` 并在使用后填充空白
3. 在传递之间最小化密码在 Kotlin 对象图中的驻留时间
---
### 🟢 LOW: AppScanner 中 userId 参数缺少非负验证
**文件**: `AppScanner.kt` 多处
```kotlin
suspend fun scanThirdParty(context: Context, userId: Int = 0): List<AppInfo> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list packages -3 --user $userId")
```
**问题**: `userId` 虽然类型为 `Int`,直接插值到 shell 命令中。如果传入负数(如 -1可能导致意外行为。但 `userId` 来自 Spinner 选择或 `UserId` 值类(已验证非负),因此实际风险很低。
**建议**: 在 UI 层、`UserId` 值类或 `AppScanner` 入口处增加正数验证。
---
### 🟢 LOW: SMB 传输默认关闭签名/加密
**文件**: `SmbTransport.kt` 第 26 行
```kotlin
private val smbSigning: Boolean = false
```
**问题**: SMB 签名和加密默认禁用。在不安全的网络中,攻击者可进行 SMB 中继攻击。代码注释说明"多数家庭服务器不支持",是合理的取舍。
**建议**: 在 UI 配置页面添加 SMB 签名开关,让用户根据网络环境决定。
---
### 🟢 LOW: WebDAV 传输缺少证书固定
**文件**: `WebdavTransport.kt` 第 22-28 行
```kotlin
private val sardine: Sardine by lazy {
OkHttpSardine().apply {
if (username.isNotEmpty()) {
setCredentials(username, password)
}
}
}
```
**问题**: `OkHttpSardine` 使用默认的 HTTPS 配置没有自定义证书验证或证书固定Certificate Pinning。中间人攻击MITM可窃取 WebDAV 的备份凭据。
**建议**: 对于重视安全的场景,可选支持证书固定,或在 UI 中显示当前 HTTPS 证书指纹。
---
## 正向发现(设计良好的安全实践)
| 实践 | 文件 | 说明 |
|------|------|------|
| ✅ 密码通过环境变量传递 | `ResticEnvResolver.kt` | RESTIC_PASSWORD 通过 env 传递,不在命令行中出现 |
| ✅ `shellEscape()` 一致使用 | `root/RootShell.kt:15` | 所有 shell 拼接参数都经过了转义 |
| ✅ `execSafe()` 安全方法 | `root/RootShell.kt:95-101` | 提供自动参数转义的执行方法 |
| ✅ ProcessBuilder 列表参数 | `ResticCommandRunner.kt` | 使用 List<String> 参数,无 shell 拼接 |
| ✅ `isArchiveSafe()` 路径穿越检查 | `RestoreOperation.kt:220-232` | 解压前检查 `..` 和危险符号链接 |
| ✅ 类型安全的值类 | `DomainTypes.kt` | `PackageName``UserId` 提供编译期类型安全 |
| ✅ 定时命令超时 | `RootShell.kt:26` | 120 秒超时防止命令挂死 |
| ✅ 取消传播 | 多处 | CancellationException 正确重新抛出 |
| ✅ RESTIC_PASSWORD 不记录日志 | `ResticCommandRunner.kt:42` | 明确注释不记录密码 |
---
## 风险优先级建议
### 立即修复 (CRITICAL)
1. **BackupConfig.kt**: 使用 `EncryptedSharedPreferences` 替换明文配置存储
2. **BackupConfig.kt**: 保存后设置文件权限为 `MODE_PRIVATE`
### 下一版本修复 (HIGH)
3. **ResticRestBridge.kt**: 绑定到 127.0.0.1 而非 0.0.0.0
4. **RestoreOperation.kt**: `isArchiveSafe` 增加绝对路径检查;解压到临时目录再移动
### 规划修复 (MEDIUM)
5. **RestoreOperation.kt**: SSAID XML 操作改为 XML 解析器而非 Sed
6. **ResticRestBridge.kt**: JSON 改用序列化库构建
7. **RootShell.kt**: 生产环境禁用详细日志
8. **ResticCommandRunner.kt**: 截断或保护仓库 URL 日志
### 可选改进 (LOW)
9. **SmbTransport.kt**: 考虑默认启用 SMB 签名
10. **WebdavTransport.kt**: 可选证书固定支持
11. **AppScanner.kt**: 添加 userId 验证
---
*注意: 本报告未包含 `memory://root/memory_summary.md` 中记录的 7 个已知待处理项。*

484
silent-failure-review.md Normal file
View File

@@ -0,0 +1,484 @@
# 静默失败审查报告 — android-backup-gui
> 审查日期: 2026-06-06
> 审查范围: 37 个 Kotlin 源文件
> 已排除: memory://root 已知的 7 个待处理项
---
## 严重程度分级
- **CRITICAL**: 数据静默损坏或丢失,用户无法感知
- **HIGH**: 错误被吞没,导致后续操作基于错误假设继续
- **MEDIUM**: 错误被吞没但影响范围有限,或仅影响辅助功能
- **LOW**: 微小错误处理缺失,实际影响小
---
## 发现清单
### F1 [HIGH] — SMB 上传大小不匹配不报告错误
**文件**: `SmbTransport.kt:103-109`
**类型**: 未检查的返回值 / 静默数据损坏
```kotlin
if (actualSize != fileSize) {
Log.w(TAG, "upload size mismatch: local=$fileSize smb=$actualSize")
SmbFileOutputStream(remote).use { it.write(ByteArray(0)) }
val retrySize = freshRemote.length()
Log.w(TAG, "upload retry: smb=$retrySize bytes")
}
// 继续返回 Success(Unit)
```
即使 SMB 端实际存储的字节数与本地不一致,`upload()` 仍返回 `AppResult.Success(Unit)`。写入零字节空数组的"修复"尝试没有验证效果。如果 SMB 服务器写入缓存有问题或磁盘空间不足restic blob 数据可能部分损坏,而上层调用者 (`RestBridgeRunner`) 不知道。
**建议**: 当 `actualSize != fileSize` 时,应返回 `err(AppError.Remote("SMB 上传大小不匹配: local=$fileSize vs smb=$actualSize", "upload"))`
---
### F2 [HIGH] — backupUserData 全失败时返回成功
**文件**: `BackupOperation.kt:255-257`
**类型**: 错误替换/空回退
```kotlin
if (!archiveCreated) {
Log.w(TAG, "backupUserData: $packageName all methods failed ...")
return true // 返回成功!
}
```
当三种数据备份方法全部失败时目录不存在、权限不足、tar 不可用),函数返回 `true`。上层调用者 (`BackupOperation.backupApps` line 131) 看到 `true` 就认为数据备份成功,累加 `successAtomic`,用户看到的报告就是"成功"。应用的用户数据被静默跳过。
**建议**: 改为 `return false` 让调用者知道数据备份实际失败。如果需要容错(某些应用确实没有数据目录),应在 `backupUserData` 外部判断,或返回区分"跳过"和"失败"的信号。
---
### F3 [HIGH] — CancellationException 被空 catch 吞没
**文件**: `ResticBackup.kt:55-58`, `ResticBackup.kt:73-77`, `ResticBackup.kt:117-120`, `ResticBackup.kt:130-134`
**类型**: 异步错误丢失
```kotlin
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") emit(progress)
} catch (_: Exception) { }
```
`catch (_: Exception)` 会捕获 `kotlinx.coroutines.CancellationException`。如果协程在 JSON 解析期间被取消,取消信号被吞没,进度回调继续运行。虽然在 `runResticStreaming`/`runResticWithStdin` 内部也有协程活跃检查(`!coroutineContext.isActive`),但取消信号仍可能延迟或丢失。
**建议**: 在空 catch 前加 `catch (e: CancellationException) { throw e }`,或改用 `catch (e: Exception) { if (e is CancellationException) throw e }`
---
### F4 [HIGH] — WebDAV mkdirs 完全失败时仍返回成功
**文件**: `WebdavTransport.kt:153-155`
**类型**: 错误替换
```kotlin
} catch (e: Exception) {
Log.w(TAG, "mkdirs failed: $remotePath${e.message}")
AppResult.Success(Unit) // best-effort
}
```
即使所有目录层级都无法创建,该方法返回 `AppResult.Success(Unit)`。注释说"upload will fail if dir can't be created",但上传可能在更深层的操作上以不同的错误信息失败(如"permission denied" vs "directory not found"),使诊断更加困难。上层调用者无法区分"目录已存在"和"完全无法创建"。
**建议**: 仅在确定目录确实存在时返回 Success如 SMB 实现中检测 `STATUS_OBJECT_NAME_COLLISION`)。对所有其他异常应返回 `err(AppError.Remote(...))`
---
### F5 [MEDIUM] — WifiManager.restore 始终返回 true
**文件**: `WifiManager.kt:54-85`
**类型**: 错误替换
整个 `restore()` 方法始终返回 `true`line 84即使
- `findWifiConfigPath()` 返回 null 且 fallback 路径无法创建目录line 63-64 返回 false但被统一 return@withContext false 处理... 等等这里 line 84 是最后一行)
- 实际上 line 63 `return@withContext false` 确实会提前返回。但如果成功执行到 line 84无论如何都返回 true。中间 `cp``chown``chmod` 的失败仅被记录日志,不通知调用者。
**建议**: `cp``chmod`/`chown` 失败时应返回 false。当前 RestoreFragment 中 `WifiManager.restore(dir)` 的返回值没有被使用,但接口应该诚实。
---
### F6 [MEDIUM] — ResticWrapper.getLatestSnapshotAppDetails 静默返回 null
**文件**: `ResticWrapper.kt:270-275`, `ResticWrapper.kt:288`
**类型**: 空回退
```kotlin
is AppResult.Failure -> {
Log.w(TAG, "getLatestSnapshotAppDetails: listSnapshots failed: ...")
null
}
// 和
is AppResult.Failure -> return@withContext null
```
`listSnapshots()``dump()` 失败时返回 `null`。调用者 (`BackupFragment.kt:228`) 仅检查 `snapshotApps != null`,看到 null 就跳过累积快照逻辑。用户不知道仓库存在但无法读取——也可能是仓库密码错误、网络问题或权限问题。但此行为在 API 文档中有意说明,且后续备份仍能工作,只是失去了累积合并能力。
**建议**: 考虑返回 `AppResult<Map<String, SnapshotAppInfo>?>` 以区分"无快照"和"读取失败"。或者增加 UI 通知。
---
### F7 [MEDIUM] — parseAppDetailsJson 捕获所有异常
**文件**: `ResticWrapper.kt:315-317`
**类型**: 被吞没的异常
```kotlin
} catch (_: Exception) {
Log.w(TAG, "parseAppDetailsJson: failed to parse JSON")
}
```
捕获所有 `Exception` 类型(包括 `CancellationException``OutOfMemoryError` 等)。虽然当前函数在 `Dispatchers.IO` 上下文外的同步路径调用,但应该缩小异常范围。
**建议**: 改为 `catch (e: org.json.JSONException)`
---
### F8 [MEDIUM] — StreamingBackup mkfifo 失败不报告
**文件**: `StreamingBackup.kt:50`
**类型**: 未检查的返回值
```kotlin
RootShell.exec("mkfifo '${fifo.absolutePath.shellEscape()}'")
```
`mkfifo` 的执行结果完全被忽略。如果 `mkfifo` 失败例如文件系统只读、磁盘满FIFO 文件不存在,后续 `restic backup --stdin` 会以模糊的错误失败。`StreamingBackup.prepareStreaming` 返回的 `StreamingResult` 将包含无效的 FIFO 路径。调用者在 `BackupFragment.kt:484` 直接使用结果,没有验证 FIFO 是否创建成功。
**建议**: 检查结果并抛出异常或返回失败信号。
---
### F9 [MEDIUM] — BackupFragment 中 restore 操作结果被忽略
**文件**: `ui/BackupFragment.kt:274-284`
**类型**: 未检查的返回值
```kotlin
ResticWrapper.restore(
repoPath = config.resticRepo,
password = config.resticPassword,
snapshotId = latestSnap.shortId,
targetPath = backupRoot.absolutePath,
...
)
```
在累积备份流程中,从 restic 仓库恢复最新快照到本地暂存目录的结果完全被忽略。如果恢复失败(例如密码错误、网络中断),`backupRoot` 目录可能不完整,但备份操作继续执行。后续 `BackupOperation.backupApps` 可能会基于不完整的文件结构工作。
**建议**: 检查 `restore()``AppResult`,如果失败则终止备份流程并通知用户。
---
### F10 [MEDIUM] — StreamingBackup.launchDataProducer 的 tar 失败仅记录日志
**文件**: `StreamingBackup.kt:116-118`
**类型**: 被吞没的异常
```kotlin
if (!result.isSuccess) {
Log.w(TAG, "Data backup failed for $pkgName: ${result.error}")
}
```
单个应用的 tar 数据备份失败时仅记录日志,继续下一个应用。调用者 (`BackupFragment.kt:534`) 通过 `producerJob.await()` 等待完成,但该函数总是返回 `true`除非协程被取消。这意味着即使某些应用的数据完全没有备份调用者也认为一切正常。restic 对缺失数据无法感知——它只归档了 FIFO 中收到的内容。
**建议**: 收集失败列表并通过返回值或回调通知调用者。
---
### F11 [MEDIUM] — RestBridgeRunner 中未识别的后端静默穿透
**文件**: `RestBridgeRunner.kt:61`
**类型**: 空回退
```kotlin
val t = transportFactory(...)
?: return block(repoPath)
```
`RemoteTransport.create()` 返回 `null`(未知 backend代码直接调用 `block(repoPath)`,其中 `repoPath` 是原始路径字符串而不是桥接 URL。restic 会收到一个可能无效的仓库 URL产生令人困惑的错误"repository doesn't exist" 而不是"未知后端类型")。
**建议**: 至少记录一个错误,或抛出异常说明后端类型不支持。
---
### F12 [MEDIUM] — SMB listFiles 在无权限时静默返回空列表
**文件**: `SmbTransport.kt:165-186`
**类型**: 空回退
```kotlin
val entries = dir.listFiles()
?.map { f -> ... }
?: emptyList()
```
`SmbFile.listFiles()` 在 SMB 权限不足时可能返回 `null`。此时 `?: emptyList()` 将静默返回空列表。调用者可能认为路径是空的而不是没有读取权限。SMB 协议可以在 `SmbException` 中返回具体的 ntStatus 错误,但这里的 null 合并将错误掩盖了。
**建议**: 在 `else` 分支或 `catch` 中检查文件是否确实存在,如果存在但 listFiles 返回 null应返回错误而非空列表。
---
### F13 [MEDIUM] — backupPermissions 静默跳过
**文件**: `BackupOperation.kt:349-354`
**类型**: 被吞没的异常
```kotlin
private suspend fun backupPermissions(packageName: String, appDir: File) {
val result = RootShell.exec("dumpsys package ...")
if (result.output.isNotBlank()) {
File(appDir, "permissions.txt").writeText(result.output)
}
}
```
如果 `dumpsys package` 命令失败或输出为空,权限备份静默跳过。`backupApps` 在 line 163 调用此函数时不检查结果,也不记录错误。恢复时将没有权限文件,应用以默认权限运行。
**建议**: 至少在 `dumpsys` 命令失败时记录日志。考虑返回 `Boolean` 让调用者知晓。
---
### F14 [MEDIUM] — backupSsaid 静默跳过
**文件**: `BackupOperation.kt:331-347`
**类型**: 被吞没的异常
```kotlin
private suspend fun backupSsaid(packageName: String, appDir: File, userId: String) {
val result = RootShell.exec("cat '$ssaidFile' 2>/dev/null")
if (!result.isSuccess || result.output.isBlank()) return
// ...
}
```
如果 XML 文件无法读取或解析失败SSAID 备份完全静默跳过。SSAID 是 Google 广告标识符,丢失后用户可能收到新的 ID。
**建议**: 在 cat 命令失败时记录警告日志。
---
### F15 [MEDIUM] — initResticRepo 使用 exceptionOrNull 可能导致 null 显示
**文件**: `ui/ConfigViewModel.kt:205-207`
**类型**: 错误替换
```kotlin
Log.e(TAG, "initResticRepo failed: ${result.exceptionOrNull()?.message}")
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "初始化失败: ${result.exceptionOrNull()?.message}"
))}
```
`AppResult.exceptionOrNull()` 创建一个新的 `RuntimeException`,如果原始 `AppError.message` 为 null例如 `AppError.Restic("", -1, "")`),用户将看到 "初始化失败: null"。
**建议**: 使用 `${result.errorOrNull()?.message ?: "未知错误"}`
---
### F16 [MEDIUM] — RestBridgeRunner 中临时文件删除结果未检查
**文件**: `RestBridgeRunner.kt:85-88`
**类型**: 资源泄露
```kotlin
val blobs = cacheDir.listFiles { f -> f.name.startsWith("restic_blob_") }
if (blobs != null) {
for (f in blobs) f.delete()
}
```
临时 blob 文件删除的结果未检查,且 `listFiles` 筛选器可能遗漏子目录中的临时文件(如 `ResticRestBridge``cacheDir` 中创建 `restic_blob_*` 文件)。随着操作频繁进行,可能累积未清理的临时文件。
**建议**: 使用 `f.delete()` 的返回值进行日志记录,并考虑递归清理。
---
### F17 [LOW] — RootShell.ensureSession 静默返回 false
**文件**: `root/RootShell.kt:63-67`
**类型**: 被吞没的异常
```kotlin
suspend fun ensureSession(): Boolean = withContext(Dispatchers.IO) {
try {
Shell.getShell().isRoot
} catch (_: Exception) { false }
}
```
如果 `Shell.getShell()` 抛出任何异常(包括 `NullPointerException``RuntimeException`),静默返回 `false`。调用者无法区分"没有 root 权限"和"libsu 未初始化或其他错误"。
**建议**: 记录异常。可以考虑区分不同类型的失败。
---
### F18 [LOW] — AppScanner 多项查询静默失败
**文件**: `AppScanner.kt:41,53,96`
**类型**: 空回退
```kotlin
if (!result.isSuccess) return@withContext emptyList()
```
`scanThirdParty``scanSystem``getApkPaths` 在 shell 命令失败时返回空列表。如果 `pm list packages` 因为 root 权限临时问题失败,用户看到的应用列表为空,但没有任何错误提示。
**建议**: 在 UI 层调用前检查返回的空列表并显示适当消息(已在 `BackupFragment.scanApps()` 中捕获异常,但 shell 层面的失败可能被漏过)。
---
### F19 [LOW] — backupUserData tar 命令可能静默失败
**文件**: `BackupOperation.kt:228`
**类型**: 未检查的返回值
```kotlin
val dirs = dataPaths.filter { RootShell.exec("test -d '${it.shellEscape()}'").isSuccess }.toMutableList()
```
`test -d` 在 nsenter namespace 切换后可能对某些路径返回假阴性。如果所有 `test -d` 都失败,`dirs` 为空列表,代码会转到 `else` 分支line 234-238尝试直接运行 tar而 tar 也会因为没有源路径而静默失败或产生空归档。此时 `archiveCreated` 保持 false进入 line 255 的 fallback 处理——但这个 fallback 返回 true见 F2
**建议**: 如果 `dirs` 为空且 tar 直接执行也未产生输出,应返回明确的失败信号。
---
### F20 [LOW] — WifiManager.backup 结果未在 BackupOperation 中检查
**文件**: `ui/BackupFragment.kt:310`
**类型**: 未检查的返回值
```kotlin
WifiManager.backup(File(result.outputDir))
```
WiFi 配置备份的结果完全被忽略。如果 WiFi 备份失败,用户不会收到任何通知。`WifiManager.backup()` 可以返回 `null`(失败时),但调用者没有使用返回值。
**建议**: 至少记录结果,考虑在最终摘要中显示 WiFi 备份状态。
---
### F21 [LOW] — estimateBackupSize 忽略 du 错误
**文件**: `ui/BackupFragment.kt:440-449`
**类型**: 未检查的返回值
```kotlin
val result = RootShell.exec("du -sb /data/data/$pkgEsc 2>/dev/null | cut -f1")
val size = result.output.trim().toLongOrNull() ?: 0L
```
如果 `du` 命令失败、输出为空或解析失败,该应用的估计大小为 0。最终的空间估算可能严重偏低仅用于判断是否需要流式备份可能导致本应触发流式备份的大数据集使用暂存模式。
**建议**: 考虑使用保守的默认值或根据应用大小粗略估算。
---
### F22 [LOW] — BackupOperation 中 chmod 结果未检查
**文件**: `BackupOperation.kt:173`
**类型**: 未检查的返回值
```kotlin
RootShell.exec("chmod -R 0755 '${backupRoot.absolutePath}'")
```
备份完成后设置目录权限的结果未检查。虽然不影响备份数据的完整性,但如果 `chmod` 失败,后续读取备份的用户可能会遇到权限问题。
**建议**: 至少记录 `chmod` 失败日志。
---
### F23 [LOW] — BackupFragment 中前台服务启动异常被吞没
**文件**: `ui/BackupFragment.kt:193-195`
**类型**: 被吞没的异常
```kotlin
try {
ContextCompat.startForegroundService(requireContext(), serviceIntent)
} catch (_: Exception) {}
```
如果前台服务启动失败(例如缺少权限、应用在后台),异常被完全吞没。备份操作仍然继续,但进程可能被 Android 杀死。
**建议**: 记录异常,考虑通知用户服务启动失败。
---
### F24 [LOW] — ConfigFragment 中 OperationEvent 的 InitFailed/PruneFailed 不显示错误详情
**文件**: `ui/ConfigFragment.kt:157-159`
**类型**: 错误替换
```kotlin
is OperationEvent.InitFailed -> {
Log.d(TAG, "init failed")
Snackbar.make(binding.root, "仓库初始化失败", Snackbar.LENGTH_SHORT).show()
}
```
InitFailed/PruneFailed 事件不携带错误详情,用户只看到"初始化失败"/"清理失败",不知道具体原因。实际错误消息在 ViewModel 的 `resticStatus.message` 中已经设置,但 UI 没有在 snackbar 中使用它。
**建议**: 从 ViewModel 状态读取错误详情并在 snackbar 中显示,或让 OperationEvent 携带错误消息。
---
### F25 [LOW] — RestBridgeRunner 中缓存传输不被清理
**文件**: `RestBridgeRunner.kt:58-63`
**类型**: 资源泄露
```kotlin
if (cachedTransportKey != key) {
cachedTransport?.let { Log.d(TAG, "discarding stale cached transport") }
val t = transportFactory(...)
...
cachedTransport = t
cachedTransportKey = key
}
```
当缓存键变化时,旧的 `cachedTransport`SMB 会话或 WebDAV client被直接丢弃而不关闭。对于 `SmbTransport`,内部的 `CIFSContext` 和 jcifs-ng 连接可能保持打开,直到 GC 触发 finalizer。对于 `WebdavTransport`OkHttp 客户端可能保持连接池和线程。
**建议**: 如果 `RemoteTransport` 接口添加 `close()` 方法,在替换缓存时调用。
---
## 分类统计
| 严重程度 | 数量 | 关键文件 |
|---|---|---|
| HIGH | 4 | SmbTransport, BackupOperation, ResticBackup, WebdavTransport |
| MEDIUM | 12 | ResticWrapper(2), StreamingBackup(2), BackupFragment, WifiManager, RestBridgeRunner, SmbTransport, BackupOperation(2), ConfigViewModel, AppScanner |
| LOW | 9 | RootShell, AppScanner(3), BackupFragment(3), BackupOperation, ConfigFragment, RestBridgeRunner |
**发现总数**: 25
---
## 总结与优先修复建议
### 必须修复 (HIGH)
1. **F1** (`SmbTransport.kt:103-109`) — SMB 上传后大小校验失败应返回错误,而非静默继续
2. **F2** (`BackupOperation.kt:255-257`) — `backupUserData` 全方式失败时返回 `true` 是在告知上层"数据已备份"
3. **F3** (`ResticBackup.kt:55-58` 等) — 进度回调中的空 catch 吞没 `CancellationException`,需添加重新抛出
4. **F4** (`WebdavTransport.kt:153-155`) — `mkdirs` 完全失败返回 Success 是错误替换
### 高优先级 (MEDIUM)
- **F10** — `StreamingBackup.launchDataProducer` 不传播 tar 错误
- **F12** — `SmbTransport` listFiles 返回 null 时可能是权限问题
- **F13/F14** — `backupPermissions`/`backupSsaid` 静默跳过
- **F8** — `StreamingBackup.mkfifo` 结果未检查
### 建议
整个代码库中使用 `catch (_: Exception)` 的模式需要系统性审查:应在所有协程 lambda 中的空 catch 前加 `catch (e: CancellationException) { throw e }`。关键入口点(如 `ResticBackup.kt:58`)已有 `CancellationException` 被吞没的问题。