76 Commits

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
sakuradairong
14b914252e chore: bump version to 1.2
Round 3 deep review fixes:
- F1: onDestroyView cleanup ordering (lifecycleScope before super)
- F2: try/catch guards on unprotected coroutines
- F3: CancellationException rethrow in catch blocks
- F4: Extract formatSize and resticJson to shared utilities
- F5: Resource management (stream close, waitFor timeout, AppInfo.label val)
- F6: SharedFlow buffer 4→16
2026-06-04 21:47:03 +08:00
sakuradairong
c01428b866 fix: resolve Lint API level errors
- BackupFragment: use ContextCompat.startForegroundService (API 24+)
- ResticCommandRunner: replace readAllBytes() with compat impl (API <33)
- themes: move windowLayoutInDisplayCutoutMode to values-v27
2026-06-04 21:29:44 +08:00
sakuradairong
51fe8e22c0 chore: remove embedded kmboxnet repo, add to gitignore 2026-06-04 21:21:22 +08:00
sakuradairong
f5dd61a83b refactor: Result → AppResult, DomainTypes, cleanup, and other improvements
- Replace kotlin.Result with AppResult across all transports and operations
- Introduce DomainTypes (PackageName, AppInfo) for type safety
- Add AppError sealed hierarchy for structured error handling
- Add SELinuxUtil for SELinux context restoration
- Add values-sw600dp and dimens.xml for tablet layout support
- Sync progress UI refactoring in BackupFragment/RestoreFragment
- BinaryResolver per-binary cache fix
2026-06-04 21:21:17 +08:00
sakuradairong
40f03e5bad fix: add try/finally for loading state on cancellation
Wrap initResticRepo, showResticStats, and pruneResticSnapshots
coroutine bodies in try/finally to ensure button state is restored
even when the coroutine is cancelled mid-flight.
2026-06-04 21:20:27 +08:00
sakuradairong
45f7af00b8 fix: eliminate redundant null assertions
- RemoteTransport.kt: localFiles[relPath] ?: continue instead of !!
- RestoreFragment.kt: selectedSnapshot/resticConfig ?: return@launch
- BackupFragment.kt: extract val summary = resticSummary before usage
- ResticCommandRunner.kt: var line: String instead of val l = line!!
2026-06-04 21:19:54 +08:00
sakuradairong
7ef0b2c9da refactor: replace launch(Dispatchers.IO) with launch { withContext(IO) }
Idiomatic coroutine pattern: launch on the default dispatcher,
wrap blocking IO work in withContext(Dispatchers.IO). This ensures
the coroutine body starts on the correct dispatcher and only
the blocking work switches to IO.
2026-06-04 21:19:04 +08:00
sakuradairong
6fa15af565 fix: use runBlocking in onCleared() instead of cancelled viewModelScope
viewModelScope is already cancelled when onCleared() runs, so
viewModelScope.launch(Dispatchers.IO) never executes ResticWrapper.cleanup().
Replace with runBlocking(Dispatchers.IO) to ensure cleanup actually runs.

Add fallback cleanup in ConfigFragment.onDestroyView().
2026-06-04 21:18:24 +08:00
sakuradairong
6cdad04905 feat: improve backup quality from Android-DataBackup analysis
Phase 1 — Core Backup Quality:
- Add archive tar structure validation after compression
- Exclude cache/.ota/lib/code_cache/no_backup from data backup
- Add keystore detection with user warning
- Enhance SSAID parsing (XML attribute) and restore via settings put secure

Phase 2 — App Data Model + Multi-user:
- Add DataSizes data class, enrich AppInfo with userId/keystore/iconPath
- Add icon backup from snapshot cache or APK
- Multi-user enumeration and per-user scanning (pm list users)
- User profile selector in backup/restore UI

Phase 3 — UI + Service:
- Add sort (A-Z, size), filter (system apps toggle), select all/deselect all
- New BackupService foreground service to prevent process death
- AndroidManifest: FOREGROUND_SERVICE + POST_NOTIFICATIONS permissions

Phase 4 — Polish:
- Add LogUtil with file rotation (7-day retention, filesDir/logs/)
- Wire file logging into backup/restore entry/exit points
2026-06-02 00:54:30 +08:00
sakuradairong
5cbd21577b fix: correct TransferProgress/ByteProgress field names in snapshot progress 2026-06-01 23:29:46 +08:00
sakuradairong
1bae01de72 perf: add sync progress to restic snapshot loading 2026-06-01 23:14:17 +08:00
sakuradairong
e710c36ee2 fix: BinaryResolver per-binary cache (was sharing incorrect cross-binary state) 2026-06-01 23:03:31 +08:00
sakuradairong
c1bbef4eef feat: bundle zstd and tar binaries in jniLibs for reliable data backup
Download statically compiled ARM64 zstd (1.3MB) and tar (1.1MB)
binaries from YAWAsau/backup_script, placed in jniLibs as
libzstd_bin.so and libtar_bin.so. BinaryResolver extracts them to
filesDir/bin/ with execute permissions.

backupUserData auto-detects:
1. Bundled zstd → use absolute path
2. System zstd → use PATH lookup
3. No zstd → fall back to gzip (tar -czf)

This eliminates the 'command not found' (exit=127) issue when nsenter
+ FLAG_MOUNT_MASTER switches to a namespace with different PATH.
2026-06-01 23:01:17 +08:00
sakuradairong
4c4542e059 fix: auto-detect zstd availability, fall back to gzip when missing
With nsenter + FLAG_MOUNT_MASTER, test -d now correctly finds
/data/data/<pkg>. But zstd binary is absent on this device (previous
backups never reached tar|zstd because dirs were always empty).
Auto-detect zstd before creating archive; fall back to tar -czf (gzip)
when unavailable.
2026-06-01 22:55:08 +08:00
sakuradairong
ef78ab8bec debug: log tar stderr in each fallback step to identify actual error 2026-06-01 22:49:42 +08:00
sakuradairong
a38a483c70 fix: configure libsu with FLAG_MOUNT_MASTER and nsenter global namespace initializer
DataBackup (XayahSuSuSu) approach: configure the default libsu builder
with:
- Shell.FLAG_MOUNT_MASTER (requests su --mount-master from Magisk)
- GlobalNamespaceInitializer: runs 'nsenter --mount=/proc/1/ns/mnt sh'
  at shell init time, switching to init's global mount namespace
  where ALL /data/data/ directories are visible.

This replaces the previous 4-tier fallback hacks with a proper
shell-level fix at the libsu configuration layer.
2026-06-01 22:42:51 +08:00
sakuradairong
d0bfef41c8 fix: replace su -Z with magiskpolicy SELinux relax in backupUserData fallback 2026-06-01 22:33:16 +08:00
106 changed files with 14647 additions and 3679 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

3
.gitignore vendored
View File

@@ -21,4 +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

@@ -1,6 +1,21 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
apply plugin: 'org.jetbrains.kotlinx.kover'
kover {
reports {
filters {
excludes {
classes(
"*.BuildConfig",
"*.R",
"*.R\$*"
)
}
}
}
}
android {
namespace "com.example.androidbackupgui"
@@ -9,35 +24,45 @@ android {
applicationId "com.example.androidbackupgui"
minSdk 24
targetSdk 34
versionCode 2
versionName "1.1"
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
}
}
}
}
testOptions {
unitTests.all {
useJUnitPlatform()
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
@@ -49,27 +74,56 @@ 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"
testImplementation "io.mockk:mockk:1.13.12"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
}

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

@@ -5,9 +5,12 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<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"
@@ -22,6 +25,11 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".backup.BackupService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>

View File

@@ -1,83 +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 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.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.BackupFragment
import com.example.androidbackupgui.ui.ConfigFragment
import com.example.androidbackupgui.ui.RestoreFragment
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
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)
RootShell.configure()
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// Initialize restic binary path
ResticBinary.prepare(this)?.let { defaultResticWrapper.binaryPath = it }
// Request root access on startup
lifecycleScope.launch(Dispatchers.IO) {
RootShell.ensureSession()
}
// Initialize file-based logging and secure credential storage
LogUtil.init(filesDir)
PasswordManager.init(this)
// 启动时初始化 SMB 加密库MD4/AESCMAC避免首次 SMB 操作时延迟失败
MissingAlgoProvider.register()
// Edge-to-edge: pad toolbar below status bar
ViewCompat.setOnApplyWindowInsetsListener(binding.topAppBar) { view, insets ->
val statusBars = insets.getInsets(WindowInsetsCompat.Type.statusBars())
view.setPadding(view.paddingLeft, statusBars.top, view.paddingRight, view.paddingBottom)
insets
}
val fragments = listOf(
BackupFragment(),
RestoreFragment(),
ConfigFragment()
)
binding.viewPager.adapter = TabAdapter(this, fragments)
binding.viewPager.isUserInputEnabled = true
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
setContent {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
AppScaffold()
}
}
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

@@ -0,0 +1,188 @@
package com.example.androidbackupgui.backup
/**
* 类型化应用错误层次。所有业务层错误统一为此 sealed interface。
*
* 使用方式:
* ```
* // 失败返回
* return err(AppError.Remote("连接超时", "download", cause = e, retryable = true))
*
* // 模式匹配
* when (error) {
* is AppError.Network -> showRetry()
* is AppError.Remote -> handleRemote(error)
* is AppError.Cancelled -> ignore()
* else -> showError(error.message)
* }
* ```
*/
sealed interface AppError {
/** 人类可读的错误描述 */
val message: String
/**
* 网络/IO 类错误。
* 用于 HTTP 请求超时、DNS 解析失败、连接被拒绝等可重试的网络异常。
*
* @property retryable 默认为 true表示此错误可安全重试
*/
data class Network(
override val message: String,
val cause: Throwable? = null,
val retryable: Boolean = true
) : AppError
/**
* Root shell 命令执行错误。
* 用于 cp、tar、pm path、dumpsys 等 root 命令的非零退出。
*/
data class Shell(
override val message: String,
val command: String,
val exitCode: Int,
val stderr: String
) : AppError
/**
* 远端文件操作错误WebDAV/SMB
* 用于上传、下载、列出、删除远端文件时的协议层错误。
*
* @property phase 错误发生时所在的阶段,可取 "connecting"、"transferring"、"list"、"delete" 等
* @property isNotFound 远端路径是否存在(区分 404 和其他错误)
* @property retryable 默认为 false明确标记为可重试需业务层判断
*/
data class Remote(
override val message: String,
val phase: String,
val cause: Throwable? = null,
val isNotFound: Boolean = false,
val retryable: Boolean = false
) : AppError
/**
* 本地文件/IO 错误。
* 用于文件读写失败、磁盘空间不足、文件不存在等本地文件系统错误。
*/
data class LocalIO(
override val message: String,
val path: String,
val cause: Throwable? = null
) : AppError
/**
* restic 命令执行错误。
* 用于 restic backup / restore / snapshots / forget 等子命令返回非零退出码。
*/
data class Restic(
override val message: String,
val exitCode: Int,
val stderr: String
) : AppError
/**
* 解析/配置错误。
* 用于 JSON 解析失败、配置文件格式错误、参数校验失败等场景。
*/
data class Parse(
override val message: String,
val detail: String = ""
) : AppError
/** 操作被取消(用户中止或协程取消)。不应重试。 */
data object Cancelled : AppError {
override val message: String = "操作被取消"
}
}
/**
* 与 [AppError] 配套的类型化返回类型。
*
* 使用方式:
* ```
* fun load(): AppResult<List<Item>> {
* return AppResult.Success(items)
* // 或
* return err(AppError.Network("连接失败"))
* }
*
* // 消费
* when (val result = load()) {
* is AppResult.Success -> showItems(result.data)
* is AppResult.Failure -> showError(result.error.message)
* }
*
* // 或使用 fold / map
* result.fold(
* onSuccess = { items -> showItems(items) },
* onFailure = { error -> showError(error.message) }
* )
* ```
*/
sealed class AppResult<out T> {
data class Success<T>(val data: T) : AppResult<T>()
data class Failure(val error: AppError) : AppResult<Nothing>()
/** Returns `true` if this is a [Success]. */
val isSuccess: Boolean get() = this is Success
/** Returns `true` if this is a [Failure]. */
val isFailure: Boolean get() = this is Failure
/** Returns the success value, or `null` if this is a [Failure]. */
fun getOrNull(): T? = (this as? Success)?.data
/** Returns the success value, or [default] if this is a [Failure]. */
fun getOrDefault(default: @UnsafeVariance T): T =
(this as? Success)?.data ?: default
/**
* Returns the success value, or throws a [RuntimeException]
* wrapping the error message if this is a [Failure].
*/
fun getOrThrow(): T =
(this as? Success)?.data
?: throw RuntimeException((this as Failure).error.message)
/**
* Returns a [RuntimeException] representing the error, or `null` if this is a [Success].
* Callers can access `.message` on the result.
*/
fun exceptionOrNull(): Throwable? =
(this as? Failure)?.let { RuntimeException(it.error.message) }
/** Returns the [AppError], or `null` if this is a [Success]. */
fun errorOrNull(): AppError? = (this as? Failure)?.error
/**
* Fold: convert either branch into a single value [R].
* [onSuccess] receives the success value; [onFailure] receives the typed [AppError].
*/
inline fun <R> fold(
crossinline onSuccess: (T) -> R,
crossinline onFailure: (AppError) -> R,
): R = when (this) {
is Success -> onSuccess(data)
is Failure -> onFailure(error)
}
inline fun <R> map(crossinline transform: (T) -> R): AppResult<R> = when (this) {
is Success -> Success(transform(data))
is Failure -> this
}
/**
* Transform the error using [transform], or pass through the success unchanged.
*/
fun mapError(transform: (AppError) -> AppError): AppResult<T> = when (this) {
is Success -> this
is Failure -> Failure(transform(error))
}
}
/**
* Create a failed [AppResult] wrapping the given [AppError].
*/
internal fun <T> err(error: AppError): AppResult<T> = AppResult.Failure(error)

View File

@@ -10,33 +10,36 @@ import kotlinx.serialization.Serializable
@Serializable
data class AppInfo(
val packageName: String,
var label: String = "",
val packageName: PackageName,
val label: String = "",
val isSystem: Boolean = false,
val apkPaths: List<String> = emptyList(),
val hasObb: Boolean = false,
val isRunning: Boolean = false,
val backupSize: Long = 0 // estimated from last backup
val backupSize: Long = 0, // estimated from last backup
// Enhanced fields (multi-user, keystore, icon)
val userId: UserId = UserId(0),
val hasKeystore: Boolean = false,
val iconPath: String? = null,
)
object AppScanner {
/** Scan all third-party installed packages. */
suspend fun scanThirdParty(context: Context): List<AppInfo> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list packages -3")
suspend fun scanThirdParty(context: Context, userId: Int = 0): List<AppInfo> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list packages -3 --user $userId")
if (!result.isSuccess) return@withContext emptyList()
val packages = result.output.lines()
.filter { it.startsWith("package:") }
.map { it.removePrefix("package:").trim() }
.filter { it.isNotEmpty() }
.map { AppInfo(packageName = it) }
.map { AppInfo(packageName = PackageName(it), userId = UserId(userId)) }
resolveLabels(context, packages)
}
/** Scan all system packages. */
suspend fun scanSystem(context: Context, config: BackupConfig): List<AppInfo> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list packages -s")
suspend fun scanSystem(context: Context, config: BackupConfig, userId: Int = 0): List<AppInfo> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list packages -s --user $userId")
if (!result.isSuccess) return@withContext emptyList()
val systemWhitelist = config.system.toSet()
@@ -48,14 +51,12 @@ object AppScanner {
.map { it.removePrefix("package:").trim() }
.filter { it.isNotEmpty() }
.filter { pkg ->
// Allow if in system whitelist or data whitelist
pkg in systemWhitelist || pkg in dataWhitelist
}
.filter { pkg ->
// Exclude if in blacklist (when blacklistMode=1, full ignore)
if (config.blacklistMode == 1) pkg !in blacklist else true
}
.map { AppInfo(packageName = it, isSystem = true) }
.map { AppInfo(packageName = PackageName(it), isSystem = true, userId = UserId(userId)) }
resolveLabels(context, packages)
}
@@ -68,15 +69,15 @@ object AppScanner {
fun resolveLabels(context: Context, packages: List<AppInfo>): List<AppInfo> {
if (packages.isEmpty()) return packages
val pm = context.packageManager
for (app in packages) {
app.label = try {
val ai = pm.getApplicationInfo(app.packageName, 0)
return packages.map { app ->
val resolvedLabel = try {
val ai = pm.getApplicationInfo(app.packageName.value, 0)
pm.getApplicationLabel(ai).toString()
} catch (_: PackageManager.NameNotFoundException) {
app.packageName
app.packageName.value
}
app.copy(label = resolvedLabel)
}
return packages
}
/** Get APK paths for a package. */
@@ -90,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) {
@@ -112,7 +103,69 @@ object AppScanner {
val result = RootShell.exec("pidof '${packageName.shellEscape()}'")
result.output.isNotBlank()
}
/** Check if an app has keystore entries (critical — keystore keys can be lost on backup). */
suspend fun hasKeystore(packageName: String): Boolean = withContext(Dispatchers.IO) {
// Resolve the app's UID first
val uidResult = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep 'userId=' | head -1")
val uid = uidResult.output
.substringAfter("userId=", "")
.substringBefore(" ")
.substringBefore(",")
.trim()
.toIntOrNull() ?: return@withContext false
// keystore_cli_v2 list as app UID — more than 1 line means has keystore entries
val ksResult = RootShell.exec("su $uid -c 'keystore_cli_v2 list' 2>/dev/null")
ksResult.output.lines().count { it.isNotBlank() } > 1
}
/** Enumerate all user profiles on the device for multi-user support. */
suspend fun enumerateUsers(): List<Pair<Int, String>> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list users")
if (!result.isSuccess) return@withContext listOf(0 to "Owner")
result.output.lines()
.filter { it.contains("UserInfo") }
.mapNotNull { line ->
val id = line.substringBefore(":").trim().toIntOrNull()
val name = line.substringAfter(":").substringBefore(":").trim()
if (id != null) id to name else null
}
}
/** Extract and save an app's icon to the given directory. */
suspend fun extractIcon(packageName: String, destDir: java.io.File, userId: Int = 0): String? = withContext(Dispatchers.IO) {
// Try snapshot cache first
val snapshotDir = "/data/system_ce/$userId/snapshots/$packageName"
val snapshotResult = RootShell.exec("ls '${snapshotDir.shellEscape()}/' 2>/dev/null | head -1")
if (snapshotResult.isSuccess && snapshotResult.output.isNotBlank()) {
val iconName = snapshotResult.output.trim()
val iconFile = java.io.File(destDir, "app_icon.png")
val copyResult = RootShell.exec("cp '${snapshotDir.shellEscape()}/${iconName.shellEscape()}' '${iconFile.absolutePath.shellEscape()}' 2>/dev/null")
if (copyResult.isSuccess && iconFile.exists()) {
return@withContext iconFile.absolutePath
}
}
// Fallback: extract from APK using aapt
val apkPaths = getApkPaths(packageName)
if (apkPaths.isNotEmpty()) {
val primaryApk = apkPaths.first()
val badgeResult = RootShell.exec("aapt d badging '$primaryApk' 2>/dev/null | grep '^application:.*icon=' | head -1")
if (badgeResult.isSuccess) {
val iconPath = badgeResult.output
.substringAfter("icon='")
.substringBefore("'")
.takeIf { it.isNotBlank() }
if (iconPath != null) {
// The icon path is relative inside the APK, extract using aapt
val iconFile = java.io.File(destDir, "app_icon.png")
RootShell.exec("aapt d raw '$primaryApk' '$iconPath' > '${iconFile.absolutePath.shellEscape()}' 2>/dev/null")
if (iconFile.exists()) {
return@withContext iconFile.absolutePath
}
}
}
}
null
}
/** Apply appList.txt-style filters. Lines starting with # are ignored, ! means apk-only. */
fun parseAppList(content: String): List<Pair<String, Boolean>> {
return content.lines()

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,81 +1,123 @@
package com.example.androidbackupgui.backup
import java.io.File
import kotlinx.serialization.Serializable
import java.io.File
/**
* Mirrors backup_settings.conf from backup_script.
* All keys correspond 1:1 with the original shell config.
*
* This is an immutable data class. Use [copy] to create modified instances.
*/
@Serializable
data class BackupConfig(
// Operation mode
var lo: Int = 0, // 0=volume key, 1=volume force, 2=keyboard
var backgroundExecution: Int = 0, // 0=foreground, 1=background
var setDisplayPowerMode: Int = 0, // 1=keep screen on during backup
var 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
var outputPath: String = "", // Custom output dir
var listLocation: String = "", // Custom appList.txt location
val outputPath: String = "", // Custom output dir
val listLocation: String = "", // Custom appList.txt location
// Update
var update: Int = 1, // 1=auto update
var cdn: Int = 1, // CDN node
val update: Int = 1, // 1=auto update
val cdn: Int = 1, // CDN node
// Filters
var mountPoint: String = "rannki|0000-1",
var user: String = "",
val mountPoint: String = "rannki|0000-1",
val user: String = "",
// Backup mode
var backupMode: Int = 1, // 1=data+apk, 0=apk only
var backupUserData: Int = 1,
var backupObbData: Int = 1,
var backupMedia: Int = 0,
var backgroundAppsIgnore: Int = 0,
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
var 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
var blacklistMode: Int = 0, // 1=full ignore, 0=apk only
var blacklist: List<String> = emptyList(),
val blacklistMode: Int = 0, // 1=full ignore, 0=apk only
val blacklist: List<String> = emptyList(),
// Whitelists
var whitelist: List<String> = emptyList(),
var system: List<String> = emptyList(),
val whitelist: List<String> = emptyList(),
val system: List<String> = emptyList(),
// Compression
var compressionMethod: String = "zstd", // zstd or tar
val compressionMethod: String = "zstd", // zstd or tar
// Terminal colors
var rgbA: Int = 226,
var rgbB: Int = 123,
var rgbC: Int = 177,
var backupWifi: Int = 1,
val rgbA: Int = 226,
val rgbB: Int = 123,
val rgbC: Int = 177,
val backupWifi: Int = 1,
// Restic deduplicated backup with rclone backend
var resticEnabled: Int = 0,
var resticRepo: String = "",
var resticPassword: String = "",
var resticBackend: String = "local", // local / webdav / smb
var resticBackendUrl: String = "",
var resticBackendUser: String = "",
var resticBackendPass: String = "",
var resticBackendShare: String = "", // SMB share name
var resticBackendDomain: String = "" // SMB domain (optional, for NTLM)
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 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)
// 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 {
val config = BackupConfig()
if (!file.exists()) return config
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 ->
@@ -84,103 +126,137 @@ 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", " ") }
}
config.lo = int("Lo")
config.backgroundExecution = int("background_execution")
config.setDisplayPowerMode = int("setDisplayPowerMode")
config.shellLang = str("Shell_LANG")
config.outputPath = str("Output_path")
config.listLocation = str("list_location")
config.update = int("update", default = 1)
config.cdn = int("cdn", default = 1)
config.mountPoint = str("mount_point")
config.user = str("user")
config.backupMode = int("Backup_Mode", default = 1)
config.backupUserData = int("Backup_user_data", default = 1)
config.backupObbData = int("Backup_obb_data", default = 1)
config.backupMedia = int("backup_media")
config.backgroundAppsIgnore = int("Background_apps_ignore")
config.customPath = lines("Custom_path")
config.blacklistMode = int("blacklist_mode")
config.blacklist = lines("blacklist")
config.whitelist = lines("whitelist")
config.system = lines("system")
config.compressionMethod = str("Compression_method").ifEmpty { "zstd" }
config.rgbA = int("rgb_a").let { if (it == 0) 226 else it }
config.rgbB = int("rgb_b").let { if (it == 0) 123 else it }
config.rgbC = int("rgb_c").let { if (it == 0) 177 else it }
config.backupWifi = int("backup_wifi", default = 1)
config.resticEnabled = int("restic_enabled")
config.resticRepo = str("restic_repo")
config.resticPassword = str("restic_password")
config.resticBackend = str("restic_backend").ifEmpty { "local" }
config.resticBackendUrl = str("restic_backend_url")
config.resticBackendUser = str("restic_backend_user")
config.resticBackendPass = str("restic_backend_pass")
config.resticBackendShare = str("restic_backend_share")
config.resticBackendDomain = str("restic_backend_domain")
return config
return BackupConfig(
lo = int("Lo"),
backgroundExecution = int("background_execution"),
setDisplayPowerMode = int("setDisplayPowerMode"),
shellLang = str("Shell_LANG"),
outputPath = str("Output_path"),
listLocation = str("list_location"),
update = int("update", default = 1),
cdn = int("cdn", default = 1),
mountPoint = str("mount_point"),
user = str("user"),
backupMode = int("Backup_Mode", default = 1),
backupUserData = int("Backup_user_data", default = 1),
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"),
whitelist = lines("whitelist"),
system = lines("system"),
compressionMethod = str("Compression_method").ifEmpty { "zstd" },
rgbA = int("rgb_a").let { if (it == 0) 226 else it },
rgbB = int("rgb_b").let { if (it == 0) 123 else it },
rgbC = int("rgb_c").let { if (it == 0) 177 else it },
backupWifi = int("backup_wifi", default = 1),
resticEnabled = int("restic_enabled"),
resticRepo = str("restic_repo"),
resticPassword = "", // 不用配置文件中的值,见下方迁移逻辑
resticBackend = str("restic_backend").ifEmpty { "local" },
resticBackendUrl = str("restic_backend_url"),
resticBackendUser = str("restic_backend_user"),
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.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import org.json.JSONObject
import kotlin.coroutines.coroutineContext
import kotlinx.serialization.Serializable
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
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,250 +50,766 @@ 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,
apps: List<AppInfo>,
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()
// Write app list
val appListFile = File(backupRoot, "appList.txt")
appListFile.writeText(apps.joinToString("\n") { it.packageName })
// 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.forEachIndexed { index, app ->
launch {
if (!coroutineContext.isActive) return@launch
semaphore.withPermit {
val appDir = File(backupRoot, app.packageName)
appDir.mkdirs()
emit(BackupProgress(index + 1, apps.size, app.packageName, "apk", "正在备份 APK…"))
// 1. Backup APK
val paths = AppScanner.getApkPaths(app.packageName)
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, "done", "APK 备份失败"))
return@withPermit
}
// 2. Backup user data (if configured)
if (config.backupMode == 1 && config.backupUserData == 1) {
emit(BackupProgress(index + 1, apps.size, app.packageName, "data", "正在备份数据…"))
if (!backupUserData(app.packageName, appDir, userId, config.compressionMethod)) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "数据备份失败"))
return@withPermit
}
}
// 3. Backup OBB (if configured and exists)
if (config.backupMode == 1 && config.backupObbData == 1) {
val hasObb = AppScanner.hasObbData(app.packageName)
if (hasObb) {
emit(BackupProgress(index + 1, apps.size, app.packageName, "obb", "正在备份 OBB…"))
if (!backupObb(app.packageName, appDir, config.compressionMethod)) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "OBB 备份失败"))
return@withPermit
}
}
}
// 4. Backup SSAID
emit(BackupProgress(index + 1, apps.size, app.packageName, "ssaid", "正在备份 SSAID…"))
backupSsaid(app.packageName, appDir, userId)
// 5. Backup runtime permissions
backupPermissions(app.packageName, appDir)
successAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "完成"))
}
}
// Safety check: refuse to backup inside Android/data directories
val absOut = outputDir.absolutePath
if (absOut.contains("/Android/")) {
LogUtil.e(TAG, "backupApps: refusing to backup inside Android/ directory: $absOut")
return@withContext BackupResult(0, 0, 0, absOut, 0)
}
// Create backup structure
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
if (!mkdirsForBackup(backupRoot)) {
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}")
// Read previous metadata for incremental backup comparison
val oldMetaFile = File(backupRoot, "app_details.json")
val oldMetaJson =
if (oldMetaFile.exists()) {
try {
JSONObject(readTextFile(oldMetaFile) ?: "{}")
} catch (_: Exception) {
JSONObject()
}
} else {
JSONObject()
}
// Write app list — includes ALL packages in [apps] (selected + legacy from snapshot)
val appListFile = File(backupRoot, "appList.txt")
if (!writeFileForBackup(appListFile, apps.joinToString("\n") { it.packageName.value })) {
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}'")
BackupResult(
successCount = successAtomic.get(),
failCount = failAtomic.get(),
skippedCount = skippedAtomic.get(),
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"
val isZstd = compression == "zstd"
// Resolve bundled binary paths (fall back to system PATH if not bundled)
val bundledTar = BinaryResolver.tarPath(context)
val tarCmd = bundledTar ?: "tar"
var isZstd = compression == "zstd"
val bundledZstd = if (isZstd) BinaryResolver.zstdPath(context) else null
val zstdCmd = bundledZstd ?: "zstd"
if (isZstd && bundledZstd == null) {
val zstdCheck = RootShell.exec("$zstdCmd --version 2>/dev/null")
if (!zstdCheck.isSuccess) {
Log.w(TAG, "backupUserData: zstd not available, falling back to gzip")
isZstd = false
}
}
val archiveExt = if (isZstd) ".zst" else ".gz"
val archiveRaw = File(appDir, "${packageName}_data.tar$archiveExt")
Log.d(TAG, "backupUserData: $packageName checking dirs")
// 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
val dataPaths = listOf("/data/data/$rawPkg", "/data/user_de/$userId/$rawPkg")
val dataExcludes = listOf(".ota", "cache", "lib", "code_cache", "no_backup")
// 1. Try direct paths (app's mount namespace)
val dirs = dataPaths.filter { RootShell.exec("test -d $it").isSuccess }.toMutableList()
var result: RootShell.ShellResult? = null
// 1. Try direct paths after nsenter namespace switch
var archiveCreated = false
var result: RootShell.ShellResult? = null
val dirs = dataPaths.filter { RootShell.exec("test -d '${it.shellEscape()}'").isSuccess }.toMutableList()
if (dirs.isNotEmpty()) {
Log.d(TAG, "backupUserData: $packageName test -d found dirs=$dirs")
result = runTar(dirs, outputFile, isZstd)
archiveCreated = result?.isSuccess == true
result = runTar(dirs, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
archiveCreated = archiveHasData()
Log.d(TAG, "backupUserData: $packageName step1 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
} else {
// 2. Try tar directly on direct paths (may fail in isolated namespace)
Log.d(TAG, "backupUserData: $packageName test -d all failed, trying tar directly")
result = runTar(dataPaths, outputFile, isZstd)
archiveCreated = result?.isSuccess == true || (archiveRaw.exists() && archiveRaw.length() > 0)
result = runTar(dataPaths, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
archiveCreated = archiveHasData()
Log.d(TAG, "backupUserData: $packageName step2 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
}
// 3. If still failed, try via /proc/1/root (global mount namespace)
// Use cd to avoid tar packing the /proc/1/root/ prefix into the archive.
// 3. Fallback via /proc/1/root (global mount namespace)
if (!archiveCreated) {
Log.w(TAG, "backupUserData: $packageName direct access failed, trying via /proc/1/root (global namespace)")
val globalRelPaths = dataPaths.map { it.removePrefix("/") } // e.g. "data/data/tv.danmaku.bili"
val excludeArgs = "--exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup'"
val globalCmd = if (isZstd) {
"cd /proc/1/root && tar $excludeArgs -cf - ${globalRelPaths.joinToString(" ")} 2>/dev/null | zstd -T0 -o '$outputFile.zst'"
} else {
"cd /proc/1/root && tar $excludeArgs -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ")} 2>/dev/null"
}
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"
}
result = RootShell.exec(globalCmd)
archiveCreated = result?.isSuccess == true || (archiveRaw.exists() && archiveRaw.length() > 0)
archiveCreated = archiveHasData()
Log.d(TAG, "backupUserData: $packageName step3 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
}
// 4. Last resort: try su -Z (Magisk SELinux context switch) — on Magisk 30+,
// the app's su context (untrusted_app) cannot see other apps' /data/data/.
// Switching to u:r:magisk:s0 lifts the SELinux restriction.
if (!archiveCreated) {
Log.w(TAG, "backupUserData: $packageName /proc/1/root failed, trying su -Z magisk context")
val excludeArgs = "--exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup'"
val dirList = dataPaths.joinToString(" ")
val rawOut = appDir.absolutePath + "/" + packageName + "_data.tar"
val innerCmd = if (isZstd) {
"tar $excludeArgs -cf - $dirList 2>/dev/null | zstd -T0 -o '${rawOut}.zst'"
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 {
"tar $excludeArgs -czf '${rawOut}.gz' $dirList 2>/dev/null"
RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess
}
result = RootShell.exec("su -Z u:r:magisk:s0 -c \"$innerCmd\" 2>/dev/null")
archiveCreated = result?.isSuccess == true || (archiveRaw.exists() && archiveRaw.length() > 0)
}
if (!archiveCreated) {
Log.w(TAG, "backupUserData: $packageName all methods failed — no data dirs to backup (or inaccessible)")
return true
}
// Verify integrity
val verifyOk = if (isZstd) {
RootShell.exec("zstd -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 null to null
}
return verifyOk
// 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 null to null
}
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
isZstd: Boolean,
tarCmd: String = "tar",
zstdCmd: String = "zstd",
excludes: List<String> = emptyList(),
): RootShell.ShellResult {
val excludeArgs = "--exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup'"
val dirList = dirs.joinToString(" ")
val excludeArgs =
if (excludes.isNotEmpty()) {
excludes.joinToString(" ") { "--exclude='${it.shellEscape()}'" }
} else {
""
}
return if (isZstd) {
RootShell.exec("tar $excludeArgs -cf - $dirList 2>/dev/null | zstd -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("tar $excludeArgs -czf '$outputFile.gz' $dirList 2>/dev/null")
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null")
}
}
private suspend fun backupObb(packageName: String, appDir: File, compression: String): Boolean {
/**
* 备份单个应用的 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()
val result = when (compression) {
"zstd" -> RootShell.exec("tar -cf - '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'")
else -> RootShell.exec("tar -czf '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
}
// 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")
}
}
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")
}
return verificationOk
// Validate OBB tar structure
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 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"
val result = RootShell.exec("grep '${packageName.shellEscape()}' '$ssaidFile' 2>/dev/null")
if (result.output.isNotBlank()) {
File(appDir, "ssaid.txt").writeText(result.output)
// 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() }
if (value != null) {
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)
root.put(app.packageName, entry)
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

@@ -0,0 +1,73 @@
package com.example.androidbackupgui.backup
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
/**
* Foreground service to keep the process alive during long backup/restore operations.
* Prevents Android from killing the app during extended operations.
*/
class BackupService : Service() {
companion object {
const val CHANNEL_ID = "backup_service_channel"
const val NOTIFICATION_ID = 1001
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"
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START_BACKUP -> {
val statusText = intent.getStringExtra(EXTRA_STATUS_TEXT) ?: "正在备份…"
val notification = createNotification(statusText)
startForeground(NOTIFICATION_ID, notification)
}
ACTION_STOP_BACKUP -> {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"备份服务",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "后台备份任务持续运行通知"
setShowBadge(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
private fun createNotification(text: String): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Android Backup")
.setContentText(text)
.setSmallIcon(android.R.drawable.ic_menu_upload)
.setOngoing(true)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
}

View File

@@ -0,0 +1,49 @@
package com.example.androidbackupgui.backup
import android.content.Context
import android.util.Log
import java.io.File
/**
* Resolves paths to binaries bundled in jniLibs.
* Android's PackageManager extracts lib*.so from jniLibs to nativeLibraryDir.
* We copy them to app-private dir (writable, executable) for ProcessBuilder use.
*/
object BinaryResolver {
private const val TAG = "BinaryResolver"
private var tarPath: String? = null
private var zstdPath: String? = null
fun tarPath(context: Context): String? = cacheOrResolve(context, "libtar_bin.so", "tar_bin", ::tarPath) { tarPath = it }
fun zstdPath(context: Context): String? = cacheOrResolve(context, "libzstd_bin.so", "zstd_bin", ::zstdPath) { zstdPath = it }
private fun cacheOrResolve(
context: Context, libName: String, destName: String,
cache: () -> String?, setCache: (String?) -> Unit
): String? {
val cached = cache()
if (cached != null) return cached
val resolved = resolve(context, libName, destName)
setCache(resolved)
return resolved
}
private fun resolve(context: Context, libName: String, destName: String): String? {
val nativeLibDir = context.applicationInfo.nativeLibraryDir
val source = File(nativeLibDir, libName)
if (!source.isFile) {
Log.e(TAG, "$libName NOT FOUND at ${source.absolutePath}")
return null
}
val dest = File(context.filesDir, "bin/$destName")
if (!dest.exists() || dest.length() != source.length() || !dest.canExecute()) {
dest.parentFile?.mkdirs()
if (dest.exists()) dest.delete()
source.inputStream().use { src -> dest.outputStream().use { out -> src.copyTo(out) } }
dest.setExecutable(true)
}
Log.i(TAG, "ready: $libName -> ${dest.absolutePath} (${dest.length()} bytes) canExec=${dest.canExecute()}")
return dest.absolutePath
}
}

View File

@@ -0,0 +1,68 @@
package com.example.androidbackupgui.backup
import kotlinx.serialization.Serializable
/**
* 类型安全的包名包装。
*
* 使用 [value] 获取原始字符串,用于 Android API 调用和 shell 命令。
*
* 构造函数验证包名格式符合 Android 命名规范(字母开头、包含至少一个点、
* 仅包含字母数字下划线连字符和点),以防止注入攻击和防止 shell 转义绕过。
*
* 如果包名来源不可信,请使用 [PackageName.safe] 安全创建。
*/
@JvmInline
@Serializable
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
}
}
/**
* 类型安全的用户 ID 包装。
*
* 使用 [value] 获取原始整数值。默认值 0 表示主用户 (Owner)。
*/
@JvmInline
@Serializable
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 {
val Owner = UserId(0)
}
}

View File

@@ -0,0 +1,12 @@
package com.example.androidbackupgui.backup
import java.util.Locale
/** Format byte count to human-readable string (e.g. "1.5 MB"). */
fun formatSize(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = arrayOf("KB", "MB", "GB", "TB")
val exp = (63 - bytes.countLeadingZeroBits()) / 10
val value = bytes.toDouble() / (1L shl (exp * 10))
return "%.1f %s".format(Locale.US, value, units[exp - 1].coerceAtMost(units.last()))
}

View File

@@ -0,0 +1,85 @@
package com.example.androidbackupgui.backup
import android.util.Log
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
/**
* File-based logger with rotation support.
* Writes logs to [baseDir]/logs/YYYY-MM-dd.log, keeping up to [maxDays] days.
* Also dispatches to Android Logcat for real-time visibility.
*/
object LogUtil {
private const val TAG = "LogUtil"
private const val MAX_DAYS = 7
private var baseDir: File? = null
private val executor = Executors.newSingleThreadExecutor()
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
private val timestampFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
fun init(baseDir: File) {
this.baseDir = baseDir
executor.execute { rotateLogs() }
}
fun i(tag: String, message: String) {
Log.i(tag, message)
writeLog("I", tag, message)
}
fun w(tag: String, message: String) {
Log.w(tag, message)
writeLog("W", tag, message)
}
fun e(tag: String, message: String) {
Log.e(tag, message)
writeLog("E", tag, message)
}
private fun writeLog(level: String, tag: String, message: String) {
val dir = baseDir ?: return
executor.execute {
try {
val today = dateFormat.format(Date())
val logFile = File(File(dir, "logs"), "$today.log")
logFile.parentFile?.mkdirs()
val timestamp = timestampFormat.format(Date())
val line = "$timestamp $level/$tag: $message\n"
logFile.appendText(line)
} catch (_: Exception) {
// Silently fail — logging should never crash the app
}
}
}
private fun rotateLogs() {
val dir = baseDir ?: return
val logDir = File(dir, "logs")
if (!logDir.exists()) return
val cutoff = System.currentTimeMillis() - MAX_DAYS * 24L * 60 * 60 * 1000
logDir.listFiles()
?.filter { it.name.endsWith(".log") }
?.forEach { file ->
if (file.lastModified() < cutoff) {
file.delete()
}
}
}
/** Get all log files sorted by name (date ascending). */
fun getLogFiles(): List<File> {
val dir = baseDir ?: return emptyList()
val logDir = File(dir, "logs")
return logDir.listFiles()
?.filter { it.name.endsWith(".log") }
?.sortedBy { it.name }
?: emptyList()
}
}

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,202 +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 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 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|$share|$backendDomain|$repoPath"
if (key != transportConfigKey || transport == null) {
transport?.let { Log.i(TAG, "transport config changed ($transportConfigKey -> $key), 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 () -> Result<T>
): Result<T> {
if (backend != "smb" && backend != "webdav") return action()
return repoSyncMutex.withLock {
var shouldCleanup = false
try {
val t = ensureTransport(backend, backendUrl, backendUser, backendPass, backendShare, repoPath)
?: return@withLock Result.failure(Exception("Failed to create transport for backend: $backend"))
val localDir = File(tempRepoDir)
val emitProgress: suspend (RemoteTransport.TransferProgress) -> Unit = { p ->
withContext(Dispatchers.Main) { onProgress(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, onByteProgress)
if (syncResult.isFailure) {
shouldCleanup = true
Log.e(TAG, "syncFromRemote FAILED: ${syncResult.exceptionOrNull()?.message}")
return@withLock Result.failure(
Exception("syncFromRemote failed: ${syncResult.exceptionOrNull()?.message}")
)
}
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, onByteProgress)
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@withLock Result.failure(
Exception("syncToRemote failed: ${uploadResult.exceptionOrNull()?.message}")
)
}
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
Result.failure(e)
} finally {
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,14 +1,7 @@
package com.example.androidbackupgui.backup
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.File
import kotlinx.serialization.Serializable
/** Thrown by transports when a remote directory genuinely does not exist (HTTP 404). */
class FileNotFoundException(path: String) : Exception("Directory not found: $path")
/**
* Unified abstraction for remote file transport (SMB / WebDAV).
@@ -38,67 +31,21 @@ interface RemoteTransport {
val currentFile: String
)
suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {}): Result<Unit>
suspend fun download(remotePath: String, localPath: String, onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {}): Result<Unit>
suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {}): AppResult<Unit>
suspend fun download(remotePath: String, localPath: String, onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {}): AppResult<Unit>
/** List entries in a remote directory (files and subdirectories). */
suspend fun listFiles(remoteDir: String): Result<List<RemoteFileInfo>>
suspend fun listFiles(remoteDir: String): AppResult<List<RemoteFileInfo>>
/** Create a directory and any missing parents on the remote. */
suspend fun mkdirs(remotePath: String): Result<Unit>
suspend fun mkdirs(remotePath: String): AppResult<Unit>
suspend fun delete(remotePath: String): Result<Unit>
suspend fun exists(remotePath: String): Result<Boolean>
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 () -> Result<T>
): Result<T> {
var lastError: Result<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 ?: Result.failure(Exception("$tag: max retries exceeded"))
}
fun create(
backend: String,
@@ -120,248 +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 = {}
): Result<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 Result.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 Result.failure(
Exception("syncFromRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}")
)
}
// 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]!!
Log.i(TAG, "syncFromRemote deleting stale local: $relPath")
try { localFile.delete() } catch (_: Exception) {}
}
onProgress(TransferProgress("complete", transferred, syncTotal, "已传输: $transferred 跳过: $skipped"))
Result.success(Unit)
} catch (e: Exception) {
Result.failure(Exception("syncFromRemote failed: ${e.message}", 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 = {}
): Result<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 Result.failure(
Exception("syncToRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}")
)
}
// 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"))
Result.success(Unit)
} catch (e: Exception) {
Result.failure(Exception("syncToRemote failed: ${e.message}", 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.exceptionOrNull()
// 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 is FileNotFoundException) {
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
}
}
}

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 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 kotlinx.serialization.json.Json
import java.io.File
import kotlin.coroutines.coroutineContext
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
private val resticJson = Json { ignoreUnknownKeys = true }
/**
* 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 = "ResticWrapper"
private val TAG = "ResticBackup"
var cacheDir: String = ""
var backendDomain: String = ""
// ── Backup ─────────────────────────────────────────
@@ -36,51 +39,69 @@ class ResticBackup(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
): Result<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 Result.failure(Exception("restic backup failed: ${result.stderr}"))
return@withContext err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
}
parseBackupSummary(result.stdout)
}
}
// ── Internal helpers ───────────────────────────────
/** Parse the JSON summary from the end of restic backup output. */
private fun parseBackupSummary(stdout: String): Result<ResticWrapper.BackupSummary> {
private fun parseBackupSummary(stdout: String): AppResult<ResticWrapper.BackupSummary> {
val lines = stdout.lines()
for (i in lines.indices.reversed()) {
val line = lines[i].trim()
if (!line.startsWith("{")) continue
try {
val summary = resticJson.decodeFromString<ResticWrapper.BackupSummary>(line)
if (summary.messageType == "summary" && summary.snapshotId.isNotEmpty()) return Result.success(summary)
} catch (_: Exception) { /* keep looking */ }
if (summary.messageType == "summary" && summary.snapshotId.isNotEmpty()) return AppResult.Success(summary)
} catch (_: Exception) {
// keep looking
}
}
return Result.failure(Exception("No summary found in restic output"))
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 = false // call prepare() instead
fun isReady(): Boolean = cachedBinaryPath != null
}

View File

@@ -3,10 +3,13 @@ package com.example.androidbackupgui.backup
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import com.example.androidbackupgui.backup.AppError
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.File
import kotlin.coroutines.coroutineContext
import java.io.ByteArrayOutputStream
import java.io.InputStream
import kotlinx.serialization.Serializable
/**
@@ -28,10 +31,25 @@ class ResticCommandRunner {
)
/** Build the full command list to run restic. */
fun buildCommandArgs(args: List<String>): List<String> {
val cmd = listOf(binaryPath) + args
Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args cmd=$cmd")
return cmd
fun buildCommandArgs(args: List<String>): List<String> =
(listOf(binaryPath) + args).also { cmd ->
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). */
@@ -39,6 +57,8 @@ class ResticCommandRunner {
val cmdArgs = buildCommandArgs(args)
Log.i(TAG, "runRestic cmd=${cmdArgs.joinToString(" ")}")
Log.d(TAG, "runRestic REPOSITORY=${env["RESTIC_REPOSITORY"]}")
// NOTE: Do NOT log RESTIC_PASSWORD or any value derived from it.
// RESTIC_REPOSITORY is safe to log (does not contain secrets).
env["TMPDIR"]?.let { File(it).mkdirs() }
return try {
val pb = ProcessBuilder(cmdArgs)
@@ -46,25 +66,29 @@ class ResticCommandRunner {
pb.redirectErrorStream(false)
val process = pb.start()
val stderrText = StringBuilder()
val stderrThread = Thread({
// 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 {
process.errorStream.bufferedReader().use { reader ->
var line: String?
while (reader.readLine().also { line = it } != null) {
Log.d(TAG, "restic stderr: $line")
stderrText.appendLine(line)
}
}
} catch (_: Exception) {}
}, "restic-stderr").apply { isDaemon = true; start() }
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 exitCode = process.waitFor()
stderrThread.join(5000)
val exitCode = try {
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}")
CommandResult(stdout.trim(), stderrText.toString().trim(), exitCode)
if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText.trim()}")
CommandResult(stdout.trim(), stderrText.trim(), exitCode)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "runRestic exception", e)
CommandResult("", e.message ?: "Unknown error", -1)
@@ -94,59 +118,66 @@ 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()
val stderrReader = process.errorStream.bufferedReader()
val stderrText = StringBuilder()
val stderrThread = Thread({
try { stderrReader.use { stderrText.append(it.readText()) } } catch (_: Exception) {}
}, "restic-stderr").apply { isDaemon = true; start() }
try {
var line: String?
while (reader.readLine().also { line = it } != null) {
var line = reader.readLine()
while (line != null) {
if (!coroutineContext.isActive) {
process.destroy()
break
}
val l = line!!
stdoutText.appendLine(l)
onLine(l)
stdoutText.appendLine(line)
onLine(line)
line = reader.readLine()
}
} finally {
try { reader.close() } catch (_: Exception) {}
}
stderrThread.join(5000)
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}")
if (stderrText.isNotEmpty()) Log.w(TAG, "runResticStreaming stderr: ${stderrText}")
CommandResult(stdoutText.toString().trim(), stderrText.toString().trim(), exitCode)
CommandResult(stdoutText.toString().trim(), stderrText.trim(), exitCode)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "runResticStreaming exception", e)
try { process?.destroy() } catch (_: Exception) {}
CommandResult("", e.message ?: "Unknown error", -1)
}
}
}
/**
* Compat implementation of InputStream.readAllBytes() for API < 33.
* Reads the entire stream into a byte array.
*/
internal fun InputStream.readAllBytesCompat(): ByteArray {
val buffer = ByteArrayOutputStream()
val data = ByteArray(4096)
while (true) {
val n = read(data)
if (n == -1) break
buffer.write(data, 0, n)
}
return buffer.toByteArray()
}

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

@@ -0,0 +1,6 @@
package com.example.androidbackupgui.backup
import kotlinx.serialization.json.Json
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
internal val resticJson = Json { ignoreUnknownKeys = true }

View File

@@ -1,22 +1,69 @@
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.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,
@@ -26,23 +73,39 @@ class ResticMaintenance(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<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) Result.success(result.stdout)
else Result.failure(Exception("restic prune failed: ${result.stderr}"))
}
}
): AppResult<String> =
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,
@@ -52,23 +115,18 @@ class ResticMaintenance(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<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) Result.success(result.stdout)
else Result.failure(Exception("restic check failed: ${result.stderr}"))
}
}
// ── Stats ──────────────────────────────────────────
): AppResult<String> =
runCommand(
"check",
"restic check 失败",
repoPath,
password,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
suspend fun stats(
repoPath: String,
@@ -78,19 +136,16 @@ class ResticMaintenance(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<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) Result.success(result.stdout)
else Result.failure(Exception("restic stats failed: ${result.stderr}"))
}
}
): AppResult<String> =
runCommand(
"stats",
"restic stats 失败",
repoPath,
password,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
}

View File

@@ -1,22 +1,37 @@
package com.example.androidbackupgui.backup
import android.util.Log
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(
@@ -27,42 +42,114 @@ class ResticRepoInit(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<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 Result.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 Result.success(Unit)
}
// Config exists but repo is corrupted (wrong password, missing keys, etc.)
return@withRemoteSync Result.failure(
Exception("仓库已存在但无法验证: ${verify.stderr.ifEmpty { "密码错误或密钥缺失" }}。请删除远端仓库后重试。")
)
}
Result.failure(Exception("restic init failed: ${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 kotlinx.serialization.json.Json
import kotlin.coroutines.coroutineContext
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
private val resticJson = Json { ignoreUnknownKeys = true }
/**
* 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 = {}
): Result<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) Result.success(Unit)
else Result.failure(Exception("restic restore failed: ${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 = {},
): Result<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) Result.success(result.stdout)
else Result.failure(Exception(result.stderr.ifEmpty { "restic dump failed with exit code ${result.exitCode}" }))
): 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 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 kotlinx.serialization.json.Json
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
private val resticJson = Json { ignoreUnknownKeys = true }
/**
* 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 = {},
): Result<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 Result.failure(Exception("restic snapshots failed: ${result.stderr}"))
return@withContext err(AppError.Restic("restic snapshots 失败", result.exitCode, result.stderr))
}
try {
val snapshots = resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
result.stdout.ifEmpty { "[]" }
)
Result.success(snapshots.sortedByDescending { it.time })
val snapshots =
resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
result.stdout.ifEmpty { "[]" },
)
AppResult.Success(snapshots.sortedByDescending { it.time })
} catch (e: Exception) {
Result.failure(Exception("Failed to parse snapshot JSON: ${e.message}"))
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 = {},
): Result<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) Result.success(result.stdout)
else Result.failure(Exception("restic forget failed: ${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,13 +1,17 @@
package com.example.androidbackupgui.backup
import android.util.Log
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 java.io.File
import kotlinx.coroutines.withContext
import kotlin.coroutines.coroutineContext
import kotlinx.serialization.Serializable
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.
@@ -15,60 +19,86 @@ import kotlinx.serialization.SerialName
* 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
@@ -78,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 ─────────────────────────
@@ -91,12 +128,16 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<Unit> = repoInit.init(
repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
)
): AppResult<Unit> =
repoInit.init(
repoPath,
password,
backend,
backendUrl,
backendUser,
backendPass,
backendShare,
)
// ── Backup ─────────────────────────────────────────
@@ -115,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(
@@ -129,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 = {}
): Result<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 ────────────────────────────────────────
@@ -151,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 = {}
): Result<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 ──────────────────────────────────────
@@ -172,13 +268,18 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<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 ────────────────────────────
@@ -191,13 +292,17 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<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,
@@ -211,13 +316,117 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<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 ────────────────────────────────────
@@ -229,13 +438,16 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<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,
@@ -245,13 +457,16 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<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,
@@ -261,28 +476,42 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<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,24 +1,25 @@
package com.example.androidbackupgui.backup
import android.content.Context
import android.util.Log
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import android.util.Log
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
@@ -26,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,
)
/**
@@ -42,161 +43,342 @@ object RestoreOperation {
* @param filterPkgs if non-null, only restore packages in this set
*/
suspend fun restoreApps(
context: Context,
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()
// 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()
}
// 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"
val packages = if (filterPkgs != null) {
allPackages.filter { it in filterPkgs }
} else {
allPackages
}
// 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 successAtomic = AtomicInteger(0)
val failAtomic = AtomicInteger(0)
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 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 successAtomic = AtomicInteger(0)
val failAtomic = AtomicInteger(0)
val semaphore = Semaphore(2)
supervisorScope {
packages.forEachIndexed { index, pkg ->
launch {
if (!coroutineContext.isActive) return@launch
semaphore.withPermit {
val appBackupDir = File(backupDir, pkg)
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(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(appBackupDir)
// 4. Restore OBB
emit(RestoreProgress(index + 1, packages.size, pkg, "obb", "正在恢复 OBB…"))
restoreObb(pkg, appBackupDir)
// 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
RestoreResult(successAtomic.get(), failAtomic.get(), 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(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
// 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 result = RootShell.exec("pm install-create -r -t 2>/dev/null")
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"
RootShell.exec("pm install-write '${sessionId.shellEscape()}' '$sessionName' '${apk.absolutePath.shellEscape()}'")
}
val commit = RootShell.exec("pm install-commit '${sessionId.shellEscape()}'")
return commit.isSuccess
// 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")
}
}
// Single APK install
val result = RootShell.exec("pm install -r -t $apkPaths")
return result.isSuccess
suspend fun doInstall(): Boolean {
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("]")
if (sessionId != null) {
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
}
}
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
}
suspend fun isInstalled(): Boolean {
val verifyResult = RootShell.exec("pm list packages '${packageName.shellEscape()}' 2>/dev/null")
return verifyResult.output.contains(packageName)
}
// First install attempt
val firstOk = doInstall()
if (!firstOk) {
LogUtil.e(TAG, "installApk: $packageName — first install attempt failed")
return false
}
// Verify installation succeeded
if (isInstalled()) {
Log.i(TAG, "installApk: $packageName installed and verified")
return true
}
// 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")
return false
}
if (isInstalled()) {
Log.i(TAG, "installApk: $packageName installed and verified (after retry)")
return true
}
Log.e(TAG, "installApk: $packageName — install reported success but package not found after retry")
return false
}
private suspend fun restoreData(appDir: File) {
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
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(" ")
for (archive in dataFiles) {
val archivePath = archive.absolutePath.shellEscape()
Log.d(TAG, "restoreData: found archive ${archive.name}")
if (!isArchiveSafe(archive)) {
Log.w(TAG, "restoreData: archive NOT SAFE, skipping: ${archive.name}")
continue
if (!isArchiveSafe(archive, zstdCmd)) {
Log.w(TAG, "restoreData: archive NOT SAFE (继续执行): ${archive.name}")
// 安全检测失败时仍继续——存档由备份操作自身创建,安全可信
}
val cmd = when {
archive.name.endsWith(".zst") ->
"zstd -d -c '$archivePath' | tar -xf - -C / 2>/dev/null"
archive.name.endsWith(".gz") ->
"tar -xzf '$archivePath' -C / 2>/dev/null"
archive.name.endsWith(".tar") ->
"tar -xf '$archivePath' -C / 2>/dev/null"
else -> { Log.w(TAG, "restoreData: unknown archive type ${archive.name}"); continue }
}
val result = RootShell.exec(cmd)
// 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 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}")
}
}
// Restore SELinux context on extracted data directories
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")
}
if (context != null) {
Log.d(TAG, "restoreData: restoring SELinux context on $dataPath: $context")
SELinuxUtil.chcon(context, dataPath)
} else {
Log.w(TAG, "restoreData: could not determine SELinux context for $dataPath")
}
}
return anyExtracted
}
/**
@@ -204,109 +386,402 @@ object RestoreOperation {
* or symbolic links pointing outside the tree.
* Accepts both absolute and relative paths — tar implementations vary.
*/
private suspend fun isArchiveSafe(archive: File): Boolean {
val listCmd = if (archive.name.endsWith(".zst")) {
"zstd -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")) {
val fallbackCmd = "$zstdCmd -d -c '${archive.absolutePath.shellEscape()}' 2>/dev/null | tar tf - 2>/dev/null"
result = RootShell.exec(fallbackCmd)
}
val result = RootShell.exec(listCmd)
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) {
val obbFiles = appDir.listFiles()
?.filter { it.name.contains("_obb.tar") }
?: 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/*'" }
var anyExtracted = false
for (archive in obbFiles) {
if (!isArchiveSafe(archive)) continue
if (!isArchiveSafe(archive, zstdCmd)) continue
val archivePath = archive.absolutePath.shellEscape()
when {
archive.name.endsWith(".zst") -> {
RootShell.exec("zstd -d -c '$archivePath' | tar -xf - -C / 2>/dev/null")
}
archive.name.endsWith(".gz") -> {
RootShell.exec("tar -xzf '$archivePath' -C / 2>/dev/null")
}
archive.name.endsWith(".tar") -> {
RootShell.exec("tar -xf '$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}")
}
}
// Fix OBB permissions
RootShell.exec("chown -R 1023:1023 /storage/emulated/0/Android/obb/${packageName.shellEscape()}/ 2>/dev/null")
// Fix OBB permissions: resolve GID from parent directory instead of hardcoding 1023
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 ssaidLine = ssaidFile.readText().trim()
if (ssaidLine.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")
}
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
val pkgEsc = packageName.shellEscape()
val ssaidEsc = ssaidLine.shellEscape()
name.endsWith(".gz") -> {
RootShell.exec("$tarCmd -xzf '$archivePath' -C / 2>/dev/null")
}
// Remove existing entry for this package, insert new one before </settings>
RootShell.exec(
"grep -v '${pkgEsc}' '$targetFile' > '$targetFile.tmp' && " +
"sed -i '\$ i ${ssaidEsc}' '$targetFile.tmp' && " +
"mv '$targetFile.tmp' '$targetFile'"
)
}
name.endsWith(".tar") -> {
RootShell.exec("$tarCmd -xf '$archivePath' -C / 2>/dev/null")
}
private suspend fun restorePermissions(packageName: String, appDir: File) {
val permFile = File(appDir, "permissions.txt")
if (!permFile.exists()) return
// dumpsys 输出格式: "android.permission.XXX: granted=true" 或 "permission.XXX: granted=true"
// 各 Android 版本输出有差异try-catch 兜底避免单权限失败中断全部
val perms = try {
permFile.readLines()
.filter { it.contains("granted=true") }
.mapNotNull { line ->
line.substringBefore(":")
.trim()
.takeIf { it.isNotEmpty() && it.contains(".") }
else -> {
Log.w(TAG, "restoreExternalData: unknown archive type ${archive.name}")
continue
}
}
} catch (_: Exception) { emptyList() }
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()
if (uid == null) {
Log.w(TAG, "restoreSsaid: could not resolve UID for $packageName")
return
}
// 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
}
// 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
}
// 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) {
val result = RootShell.exec("settings put secure ssaid_$uid '${ssaidValue.shellEscape()}'")
if (result.isSuccess) {
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via settings (uid=$uid)")
} else {
Log.e(TAG, "restoreSsaid: failed to set SSAID for $packageName: ${result.error}")
}
}
}
private suspend fun restorePermissions(
packageName: String,
appDir: File,
) {
val permFile = File(appDir, "permissions.txt")
val content = BackupOperation.readTextFile(permFile) ?: return
val parsedPerms =
content.lines().mapNotNull { line ->
val name = line.substringBefore(":").trim().takeIf { it.isNotEmpty() && it.contains(".") } ?: return@mapNotNull null
val granted = line.contains("granted=true")
Pair(name, granted)
}
if (parsedPerms.isEmpty()) return
val pkgEsc = packageName.shellEscape()
for (perm in perms) {
// 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 }
// Grant runtime permissions that were previously granted
for (perm in grantedPerms) {
val result = RootShell.exec("pm grant '$pkgEsc' '${perm.shellEscape()}' 2>&1")
if (!result.isSuccess) {
android.util.Log.w("RestoreOperation", "pm grant failed for $packageName: $perm${result.output}")
Log.w(TAG, "restorePermissions: pm grant failed for $packageName: $perm${result.output}")
}
}
// Revoke runtime permissions that were explicitly denied
for (perm in deniedPerms) {
val result = RootShell.exec("pm revoke '$pkgEsc' '${perm.shellEscape()}' 2>&1")
if (!result.isSuccess) {
// Revoking a permission that isn't granted is not an error — just log at debug level
Log.d(TAG, "restorePermissions: pm revoke for $packageName: $perm${result.output}")
}
}
Log.i(TAG, "restorePermissions: ${grantedPerms.size} granted, ${deniedPerms.size} revoked for $packageName")
}
private suspend fun fixDataOwnership(packageName: String, userId: String) {
/** Resolve app UID using multiple methods for robustness across Android versions. */
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()
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()
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()
return ds2Uid
}
private suspend fun fixDataOwnership(
packageName: String,
userId: String,
) {
val pkgEsc = packageName.shellEscape()
val uidEsc = userId.shellEscape()
val uidResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1")
val uid = uidResult.output
.substringAfter("userId=", "")
.substringBefore(" ")
.substringBefore(",")
.trim()
.toIntOrNull()
if (uid != null) {
RootShell.exec("chown -R $uid:$uid /data/data/$pkgEsc/ 2>/dev/null")
RootShell.exec("chown -R $uid:$uid /data/user_de/$uidEsc/$pkgEsc/ 2>/dev/null")
RootShell.exec("restorecon -R /data/data/$pkgEsc/ 2>/dev/null")
RootShell.exec("restorecon -R /data/user_de/$uidEsc/$pkgEsc/ 2>/dev/null")
val uid = resolveAppUid(packageName)
if (uid == null) {
Log.w(TAG, "fixDataOwnership: could not resolve UID for $packageName — data will be inaccessible")
return
}
// 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")
}
if (context != null) {
SELinuxUtil.chcon(context, dataPath)
Log.d(TAG, "fixDataOwnership: restored SELinux context on $dataPath: $context")
} else {
Log.w(TAG, "fixDataOwnership: could not determine SELinux context for $dataPath")
}
}
}
}

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

@@ -0,0 +1,43 @@
package com.example.androidbackupgui.backup
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import android.util.Log
/**
* SELinux context utilities for restoring file security labels.
* Mirrors the approach from Android-DataBackup (Xayah) SELinuxUtil.kt.
*/
object SELinuxUtil {
private const val TAG = "SELinuxUtil"
/**
* Query the SELinux context of a path.
* Returns the full SELinux label (e.g., "u:object_r:app_data_file:s0:c512,c768")
* or null if the path doesn't exist or the query fails.
*/
suspend fun getContext(path: String): String? {
val escaped = path.shellEscape()
val result = RootShell.exec("ls -Zd '$escaped' 2>/dev/null | awk 'NF>1{print \$1}'")
if (!result.isSuccess) return null
val context = result.output.trim()
return context.ifBlank { null }
}
/**
* Restore a SELinux context on a path recursively.
* Equivalent to: chcon -hR [context] [path]/
*/
suspend fun chcon(context: String, path: String): Boolean {
val ctxEsc = context.shellEscape()
val pathEsc = path.shellEscape()
val result = RootShell.exec("chcon -hR '$ctxEsc' '$pathEsc/' 2>/dev/null")
if (result.isSuccess) return true
val fallback = RootShell.exec("chcon -R '$ctxEsc' '$pathEsc/' 2>/dev/null")
if (!fallback.isSuccess) {
Log.w(TAG, "chcon failed (both primary and fallback): $path")
}
return fallback.isSuccess
}
}

View File

@@ -11,8 +11,10 @@ import jcifs.smb.SmbFileInputStream
import jcifs.smb.SmbFileOutputStream
import kotlinx.coroutines.Dispatchers
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,
@@ -21,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")
@@ -32,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")
@@ -46,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"
@@ -54,44 +69,54 @@ 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): Result<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)
override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
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)")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "upload failed: ${buildUrl(remotePath)}", e)
Result.failure(Exception("SMB upload failed: ${e.message}", e))
}
}
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): Result<Unit> =
withContext(Dispatchers.IO) {
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
retryWithBackoff(TAG, "SMB 下载") {
withContext(Dispatchers.IO) {
try {
val localFile = File(localPath)
localFile.parentFile?.mkdirs()
@@ -114,19 +139,22 @@ class SmbTransport(
}
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
Log.d(TAG, "download ${buildUrl(remotePath)} -> $localPath (${localFile.length()} bytes)")
Result.success(Unit)
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "download failed: $remotePath", e)
Result.failure(Exception("SMB download failed: ${e.message}", e))
err(AppError.Remote("SMB 下载失败", "download", cause = e))
}
}
}
override suspend fun listFiles(remoteDir: String): Result<List<RemoteTransport.RemoteFileInfo>> =
override suspend fun listFiles(remoteDir: String): AppResult<List<RemoteTransport.RemoteFileInfo>> =
withContext(Dispatchers.IO) {
try {
val dir = smbFile(remoteDir)
if (!dir.exists() || !dir.isDirectory) {
return@withContext Result.failure(FileNotFoundException(remoteDir))
return@withContext err(AppError.Remote("远端路径不存在", "list", isNotFound = true))
}
// SmbFile.getName() in jcifs-ng 2.1.x is broken — it concatenates
// parent-dir + filename without separator. Use the URL to extract
@@ -154,66 +182,87 @@ class SmbTransport(
}
?: emptyList()
Log.d(TAG, "listFiles $remoteDir -> ${entries.size} entries: ${entries.joinToString { "${it.name}(${if (it.isDirectory) "d" else "f"},${it.size})" }}")
Result.success(entries)
AppResult.Success(entries)
} catch (e: SmbException) {
if (e.ntStatus == 0xC0000034.toInt()) {
return@withContext Result.failure(FileNotFoundException(remoteDir))
return@withContext err(AppError.Remote("远端路径不存在", "list", isNotFound = true))
}
Log.e(TAG, "listFiles failed: $remoteDir", e)
Result.failure(Exception("SMB list failed: ${e.message}", e))
err(AppError.Remote("SMB 列表失败", "list", cause = e))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "listFiles failed: $remoteDir", e)
Result.failure(Exception("SMB list failed: ${e.message}", e))
err(AppError.Remote("SMB 列表失败", "list", cause = e))
}
}
override suspend fun mkdirs(remotePath: String): Result<Unit> =
override suspend fun mkdirs(remotePath: String): AppResult<Unit> =
withContext(Dispatchers.IO) {
try {
val dir = smbFile(remotePath)
if (!dir.exists()) dir.mkdirs()
Result.success(Unit)
AppResult.Success(Unit)
} catch (e: SmbException) {
// STATUS_OBJECT_NAME_COLLISION (0xC0000035): directory already exists — not an error
if (e.ntStatus == 0xC0000035.toInt()) {
Result.success(Unit)
AppResult.Success(Unit)
} else {
Log.e(TAG, "mkdirs failed: $remotePath${e.message}")
Result.failure(Exception("SMB mkdirs failed: ${e.message}", e))
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}")
Result.failure(Exception("SMB mkdirs failed: ${e.message}", e))
Log.e(TAG, "mkdirs failed: $remotePath${e::class.java.name}: ${e.message} cause=${e.cause?.message}")
err(AppError.Remote("SMB 创建目录失败", "mkdirs", cause = e))
}
}
override suspend fun delete(remotePath: String): Result<Unit> =
override suspend fun delete(remotePath: String): AppResult<Unit> =
withContext(Dispatchers.IO) {
try {
val file = smbFile(remotePath)
if (file.exists()) file.delete()
Result.success(Unit)
AppResult.Success(Unit)
} catch (e: SmbException) {
// STATUS_OBJECT_NAME_NOT_FOUND (0xC0000034): file already gone — not an error
if (e.ntStatus == 0xC0000034.toInt()) {
Result.success(Unit)
AppResult.Success(Unit)
} else {
Log.w(TAG, "delete failed: $remotePath${e.message}")
Result.failure(Exception("SMB delete failed: ${e.message}", e))
err(AppError.Remote("SMB 删除失败", "delete", cause = e))
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "delete failed: $remotePath${e.message}")
Result.failure(Exception("SMB delete failed: ${e.message}", e))
err(AppError.Remote("SMB 删除失败", "delete", cause = e))
}
}
override suspend fun exists(remotePath: String): Result<Boolean> =
override suspend fun exists(remotePath: String): AppResult<Boolean> =
withContext(Dispatchers.IO) {
try {
Result.success(smbFile(remotePath).exists())
AppResult.Success(smbFile(remotePath).exists())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(Exception("SMB exists check failed: ${e.message}", e))
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

@@ -6,20 +6,31 @@ import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
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)
}
@@ -31,73 +42,140 @@ class WebdavTransport(
return "$baseUrl/$cleanPath"
}
override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): Result<Unit> =
withContext(Dispatchers.IO) {
try {
val url = buildUrl(remotePath)
val file = File(localPath)
val fileSize = file.length()
if (fileSize > 50 * 1024 * 1024L) {
return@withContext Result.failure(
Exception("WebDAV upload: file too large (${fileSize / 1024 / 1024}MB), max 50MB")
)
}
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)
override suspend fun upload(localPath: String, remotePath: 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 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))
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "upload failed: $remotePath", e)
Result.failure(Exception("WebDAV upload failed: ${e.message}", e))
}
}
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): Result<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)")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "download failed: $remotePath", e)
Result.failure(Exception("WebDAV download failed: ${e.message}", e))
}
}
override suspend fun listFiles(remoteDir: String): Result<List<RemoteTransport.RemoteFileInfo>> =
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 {
val url = buildUrl(remoteDir)
@@ -116,7 +194,9 @@ class WebdavTransport(
isDirectory = it.isDirectory
) }
Log.d(TAG, "listFiles $remoteDir -> ${entries.size} entries")
Result.success(entries)
AppResult.Success(entries)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Only treat 404 as empty for non-root paths; the caller (listRemoteRecursive)
// handles the distinction. We propagate the error so the caller can decide.
@@ -124,14 +204,14 @@ class WebdavTransport(
if (is404) {
// Return a failure with a distinguishable marker so callers can check
Log.d(TAG, "listFiles $remoteDir -> 404 (not found)")
return@withContext Result.failure(FileNotFoundException(remoteDir))
return@withContext err(AppError.Remote("远端路径不存在", "list", isNotFound = true))
}
Log.e(TAG, "listFiles failed: $remoteDir", e)
Result.failure(Exception("WebDAV list failed: ${e.message}", e))
err(AppError.Remote("WebDAV 列表失败", "list", cause = e))
}
}
override suspend fun mkdirs(remotePath: String): Result<Unit> =
override suspend fun mkdirs(remotePath: String): AppResult<Unit> =
withContext(Dispatchers.IO) {
try {
val parts = remotePath.trimStart('/').split("/")
@@ -139,34 +219,55 @@ class WebdavTransport(
for (part in parts) {
current = if (current.isEmpty()) part else "$current/$part"
try { sardine.createDirectory(buildUrl(current)) }
catch (_: Exception) { /* already exists or parent missing, continue */ }
catch (_: Exception) { Log.w(TAG, "mkdirs: failed to create $current"); continue }
}
Result.success(Unit)
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "mkdirs failed: $remotePath${e.message}")
Result.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))
}
}
override suspend fun delete(remotePath: String): Result<Unit> =
override suspend fun delete(remotePath: String): AppResult<Unit> =
withContext(Dispatchers.IO) {
try {
val url = buildUrl(remotePath)
sardine.delete(url)
Result.success(Unit)
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "delete failed (ignoring): $remotePath${e.message}")
Result.success(Unit)
err(AppError.Remote("WebDAV 删除失败", "delete", cause = e))
}
}
override suspend fun exists(remotePath: String): Result<Boolean> =
override suspend fun exists(remotePath: String): AppResult<Boolean> =
withContext(Dispatchers.IO) {
try {
val result = sardine.exists(buildUrl(remotePath))
Result.success(result)
AppResult.Success(result)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(Exception("WebDAV exists check failed: ${e.message}", e))
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

@@ -3,6 +3,8 @@ package com.example.androidbackupgui.root
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
@@ -34,24 +36,51 @@ object RootShell {
}
/**
* Trigger root shell pre-initialization.
* Returns true if root is available.
* Note: Shell.cmd() also auto-initializes on first use, so this is optional.
* libsu shell initializer: enter global mount namespace via nsenter.
* Preserves the original PATH so that tar/zstd (from Termux etc.) remain accessible.
* Ref: DataBackup (XayahSuSuSu) uses the same nsenter pattern.
*/
private class GlobalNamespaceInitializer : Shell.Initializer() {
override fun onInit(context: android.content.Context, shell: Shell): Boolean {
shell.newJob()
.add("nsenter --mount=/proc/1/ns/mnt sh")
.add("set -o pipefail")
.exec()
return true
}
}
/** Call once at app startup to configure libsu. Safe to call multiple times. */
fun configure() {
Shell.enableVerboseLogging = true
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 }
}
/**
* Execute a shell command and return the result.
* libsu internally runs via `su`, compatible with Magisk/KernelSU/APatch.
* Commands are passed verbatim to `su -c`, so pipes and redirects work normally.
* Timeout is enforced via structured coroutine cancellation.
*/
suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult =
withContext(Dispatchers.IO) {
ensureActive()
try {
val result = withTimeout(timeoutMs) {
Shell.cmd(command).exec()
@@ -64,9 +93,24 @@ 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)
}
}
/**
* 安全执行 root shell 命令,自动 shellEscape 每个参数。
* @param parts 命令和参数列表,第一个元素是命令本身
* @param timeoutMs 超时毫秒
*/
suspend fun execSafe(
parts: List<String>,
timeoutMs: Long = COMMAND_TIMEOUT_MS
): ShellResult = exec(
command = parts.joinToString(" ") { "'${it.shellEscape()}'" },
timeoutMs = timeoutMs
)
}

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,220 +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.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.ResticBinary
import com.example.androidbackupgui.backup.ResticWrapper
import com.example.androidbackupgui.backup.WifiManager
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 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 lateinit var config: BackupConfig
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() }
}
override fun onResume() {
super.onResume()
// Re-read config so changes from ConfigFragment take effect immediately
val configFile = File(requireContext().filesDir, "backup_settings.conf")
config = BackupConfig.fromFile(configFile)
}
private fun scanApps() {
binding.backupButton.isEnabled = false
setRunning(true)
binding.statusText.text = "正在扫描应用…"
viewLifecycleOwner.lifecycleScope.launch {
val ctx = requireContext()
val thirdParty = AppScanner.scanThirdParty(ctx)
val system = AppScanner.scanSystem(ctx, config)
apps = thirdParty + system
selectedApps.clear()
selectedApps.addAll(apps.map { it.packageName })
binding.statusText.text = "共找到 ${apps.size} 个应用,全部已选中"
binding.backupButton.isEnabled = apps.isNotEmpty()
setRunning(false)
setupAppList()
}
}
private fun setupAppList() {
binding.appList.adapter = PackageListAdapter(apps, selectedApps) { pkg, checked ->
if (checked) selectedApps.add(pkg) else selectedApps.remove(pkg)
binding.statusText.text = "已选择 ${selectedApps.size}/${apps.size} 个应用"
}
}
private fun startBackup() {
val toBackup = apps.filter { it.packageName in selectedApps }
if (toBackup.isEmpty()) return
setRunning(true)
binding.backupButton.isEnabled = false
binding.scanButton.isEnabled = false
viewLifecycleOwner.lifecycleScope.launch {
val outputDir = File(config.outputPath.ifEmpty {
requireContext().filesDir.absolutePath
})
WifiManager.backup(outputDir)
val result = BackupOperation.backupApps(
apps = toBackup,
config = config,
outputDir = outputDir,
onProgress = { progress ->
val label = toBackup.find { it.packageName == progress.packageName }?.label
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
binding.statusText.text =
"[${progress.current}/${progress.total}] $name: ${progress.message}"
}
)
// If restic is enabled, snapshot the backup to a restic 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
// For local repos, verify init before attempting backup
if (config.resticBackend == "local") {
if (!File(config.resticRepo, "config").exists()) {
binding.statusText.text = "restic 本地仓库未初始化,请先在设置中初始化"
setRunning(false)
binding.scanButton.isEnabled = true
return@launch
}
}
binding.statusText.text = "正在写入 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 ->
withContext(Dispatchers.Main) {
when (progress.phase) {
"list", "download", "upload", "delete_stale" ->
binding.statusText.text = "同步中: ${progress.current}/${progress.total} 个文件"
}
}
},
onByteSyncProgress = { progress ->
withContext(Dispatchers.Main) {
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
binding.progressBar.progress = progress.bytesTransferred.toInt()
binding.statusText.text = "同步中: ${progress.currentFile}\n" +
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}"
}
},
onProgress = { progress ->
if (progress.messageType == "status") {
binding.statusText.text = "去重仓库: %.0f%% (%d/%d 个文件)".format(
progress.percentDone * 100,
progress.filesDone,
progress.totalFiles
)
}
}
)
resticResult.fold(
onSuccess = { resticSummary = it },
onFailure = { e ->
resticError = e.message
binding.statusText.text = "restic 快照失败: ${e.message}"
}
)
}
}
binding.statusText.text = buildString {
appendLine("备份完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
appendLine("耗时: ${result.elapsedMs / 1000}")
appendLine("输出: ${result.outputDir}")
if (resticSummary != null) {
appendLine()
appendLine("── Restic 快照 ──")
appendLine("ID: ${resticSummary!!.snapshotId.take(8)}")
appendLine("新增: ${resticSummary!!.dataAdded / 1024 / 1024} MB")
appendLine("文件: ${resticSummary!!.totalFilesProcessed}")
} else if (resticError != null) {
appendLine()
appendLine("── Restic 错误 ──")
appendLine(resticError!!)
}
}
setRunning(false)
binding.scanButton.isEnabled = true
}
}
private fun formatSize(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = arrayOf("KB", "MB", "GB", "TB")
val exp = (63 - bytes.countLeadingZeroBits()) / 10
val value = bytes.toDouble() / (1L shl (exp * 10))
return "%.1f %s".format(Locale.US, value, units[exp - 1].coerceAtMost(units.last()))
}
private fun setRunning(running: Boolean) {
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
}
override fun onDestroyView() {
viewLifecycleOwner.lifecycleScope.launch(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,215 +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 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
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 for derived UI updates
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.uiState.collect { state -> applyState(state) }
}
}
}
// ── 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
}
}
// ── 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().also { config ->
config.backupMode = if (binding.backupModeSwitch.isChecked) 1 else 0
config.backupUserData = if (binding.backupUserDataSwitch.isChecked) 1 else 0
config.backupObbData = if (binding.backupObbSwitch.isChecked) 1 else 0
config.backgroundAppsIgnore = if (binding.ignoreRunningSwitch.isChecked) 1 else 0
config.outputPath = binding.outputPathEdit.text?.toString() ?: ""
config.compressionMethod = binding.compressionEdit.text?.toString()?.ifEmpty { "zstd" } ?: "zstd"
config.backupWifi = if (binding.backupWifiSwitch.isChecked) 1 else 0
config.resticEnabled = if (binding.resticEnabledSwitch.isChecked) 1 else 0
config.resticRepo = binding.resticRepoEdit.text?.toString()?.trim() ?: ""
config.resticPassword = binding.resticPasswordEdit.text?.toString() ?: ""
config.resticBackend = readBackend()
config.resticBackendUrl = binding.resticBackendUrlEdit.text?.toString()?.trim() ?: ""
config.resticBackendUser = binding.resticBackendUserEdit.text?.toString()?.trim() ?: ""
config.resticBackendPass = binding.resticBackendPassEdit.text?.toString() ?: ""
config.resticBackendShare = binding.resticBackendShareEdit.text?.toString()?.trim() ?: ""
config.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() {
super.onDestroyView()
_binding = null
// cleanup is handled by ViewModel.onCleared()
}
}

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,28 +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.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.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.withContext
import java.io.File
import java.util.Locale
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(
@@ -30,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(
@@ -41,56 +47,107 @@ 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,
)
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
/**
* 类型安全的一键操作生命周期事件。
* [ConfigFragment] 应对此进行收集以触发一次性 UI 效果。
*/
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) {
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,
)
}
fun formatSize(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = arrayOf("KB", "MB", "GB", "TB")
val exp = (63 - bytes.countLeadingZeroBits()) / 10
val value = bytes.toDouble() / (1L shl (exp * 10))
return "%.1f %s".format(Locale.US, value, units[exp - 1].coerceAtMost(units.last()))
}
}
private val configFile: File by lazy {
File(getApplication<Application>().filesDir, CONFIG_FILE_NAME)
}
/** One-shot operation lifecycle events (e.g. "operation started", "operation completed"). */
private val _operationEvents = MutableSharedFlow<OperationEvent>(extraBufferCapacity = 16)
val operationEvents: SharedFlow<OperationEvent> = _operationEvents.asSharedFlow()
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)
@@ -101,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) }
}
@@ -120,12 +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) {
viewModelScope.launch(Dispatchers.IO) {
BackupConfig.toFile(formConfig, configFile)
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 = "配置导入失败")) }
}
}
}
@@ -135,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()) {
@@ -159,181 +386,379 @@ 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 {
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) },
)
result.fold(
onSuccess = {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "仓库初始化成功: ${form.repo}", initButtonEnabled = true
))}
try {
_operationEvents.emit(OperationEvent.InitStarted)
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}",
),
)
}
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}",
),
)
}
refreshResticStatus(form)
},
onFailure = { e ->
Log.e(TAG, "initResticRepo failed", e)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "初始化失败: ${e.message}", initButtonEnabled = true
))}
}
)
} finally {
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 {
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) },
)
try {
_operationEvents.emit(OperationEvent.StatsStarted)
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.exceptionOrNull()?.message}")
}
},
snapshotCount = snapshotCount,
statsButtonEnabled = true
))}
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,
),
)
}
_operationEvents.emit(OperationEvent.StatsCompleted)
} finally {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(statsButtonEnabled = true)) }
}
}
}
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 {
val forgetResult = ResticWrapper.forget(form.repo, form.password,
keepDaily = 7, keepWeekly = 4, keepMonthly = 3,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
if (forgetResult.isFailure) {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "forget 失败: ${forgetResult.exceptionOrNull()?.message}",
pruneButtonEnabled = true
))}
return@launch
try {
_operationEvents.emit(OperationEvent.PruneStarted)
// 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,
)
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,
),
)
}
return@launch
}
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "正在回收空间…")) }
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 {
_operationEvents.emit(OperationEvent.PruneFailed)
}
} finally {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(pruneButtonEnabled = true)) }
}
_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
))}
}
}
// ── 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()
viewModelScope.launch(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,75 +0,0 @@
package com.example.androidbackupgui.ui
import android.view.View
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 card = MaterialCardView(ctx).apply {
layoutParams = ViewGroup.MarginLayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply { setMargins(0, 0, 0, 8) }
radius = 12f
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(16, 12, 16, 12)
}
val cb = CheckBox(ctx).apply { id = R.id.checkbox }
val tv = TextView(ctx).apply {
id = R.id.appName
setPadding(16, 0, 0, 0)
textSize = 15f
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 }
// Avoid re-triggering listener during bind
holder.checkbox.setOnCheckedChangeListener(null)
holder.checkbox.isChecked = app.packageName in selected
holder.checkbox.setOnCheckedChangeListener { _, checked ->
onToggle(app.packageName, checked)
}
}
override fun getItemCount() = apps.size
}

View File

@@ -1,360 +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.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.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 java.util.Locale
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
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() }
}
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)
resticConfig = if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) config else null
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 = it) })
setupAppList()
}
private fun selectResticSnapshot() {
val config = resticConfig ?: return
setRunning(true)
binding.statusText.text = "正在读取 restic 快照列表…"
viewLifecycleOwner.lifecycleScope.launch {
val snapshotsResult = ResticWrapper.listSnapshots(
config.resticRepo, config.resticPassword,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare
)
if (snapshotsResult.isFailure) {
binding.statusText.text = "读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}"
setRunning(false)
return@launch
}
val snapshots = snapshotsResult.getOrThrow()
if (snapshots.isEmpty()) {
binding.statusText.text = "没有可用的 restic 快照"
setRunning(false)
return@launch
}
// 多快照时让用户选择,单个快照自动选
val chosenSnapshot = if (snapshots.size == 1) {
snapshots.first()
} else {
pickSnapshot(snapshots) ?: run {
binding.statusText.text = "已取消选择"
setRunning(false)
return@launch
}
}
// Switch to restic source
backupDir = null
selectedSnapshot = chosenSnapshot
val backupPath = selectedSnapshot!!.paths.firstOrNull() ?: run {
binding.statusText.text = "快照中找不到备份路径"
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()) {
binding.statusText.text = "无法从快照读取应用列表"
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 = it) })
binding.statusText.text = "restic 快照共 ${packages.size} 个应用,点击恢复开始"
binding.restoreButton.isEnabled = true
setRunning(false)
setupAppList()
}
}
/** 多快照时弹出选择对话框。返回用户选择的快照,取消时返回 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 {
val result = if (selectedSnapshot != null && resticConfig != null) {
// Restic restore
val snapshot = selectedSnapshot!!
val config = resticConfig!!
val backupPath = snapshot.paths.firstOrNull() ?: return@launch
val staging = File(requireContext().cacheDir, "restic_restore_${snapshot.shortId}")
staging.mkdirs()
try {
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 ->
withContext(Dispatchers.Main) {
when (progress.phase) {
"list", "download", "upload", "delete_stale" ->
binding.statusText.text = "同步中: ${progress.current}/${progress.total} 个文件"
}
}
},
onByteSyncProgress = { progress ->
withContext(Dispatchers.Main) {
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
binding.progressBar.progress = progress.bytesTransferred.toInt()
binding.statusText.text = "同步中: ${progress.currentFile}\n" +
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}"
}
},
onProgress = { msg -> binding.statusText.text = msg }
)
if (restoreResult.isFailure) {
binding.statusText.text = "restic 恢复失败: ${restoreResult.exceptionOrNull()?.message}"
setRunning(false)
binding.selectDirButton.isEnabled = true
return@launch
}
// The restored backup directory: <staging>/<original_absolute_path>
val restoredBackupDir = File(staging, backupPath.removePrefix("/"))
binding.statusText.text = "正在从恢复的备份安装应用…"
val r = RestoreOperation.restoreApps(
backupDir = restoredBackupDir,
filterPkgs = selectedPackages,
onProgress = { progress ->
val label = appInfos.find { it.packageName == 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(
backupDir = dir,
filterPkgs = selectedPackages,
onProgress = { progress ->
val label = appInfos.find { it.packageName == 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请立即重启设备后再开启应用")
}
setRunning(false)
binding.selectDirButton.isEnabled = true
}
}
private fun setRunning(running: Boolean) {
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
}
private fun formatSize(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = arrayOf("KB", "MB", "GB", "TB")
val exp = (63 - bytes.countLeadingZeroBits()) / 10
val value = bytes.toDouble() / (1L shl (exp * 10))
return "%.1f %s".format(Locale.US, value, units[exp - 1].coerceAtMost(units.last()))
}
override fun onDestroyView() {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
ResticWrapper.cleanup()
}
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
)
)

Binary file not shown.

Binary file not shown.

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,69 +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="16dp"
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>
<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: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,387 +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="16dp">
<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" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/resticBackendGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/resticBackendLocal"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="本机"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/resticBackendWebdav"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="WebDAV"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/resticBackendSmb"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="SMB"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/resticBackendRestServer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="REST"
style="@style/Widget.Material3.Button.TonalButton" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<!-- 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,89 +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="16dp"
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>
<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: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,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,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,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"
}
}
})

View File

@@ -4,8 +4,10 @@ buildscript {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
dependencies {
classpath "org.jetbrains.kotlinx:kover-gradle-plugin:0.9.8"
classpath 'com.android.tools.build:gradle:8.2.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"

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% 的源代码量

Some files were not shown because too many files have changed in this diff Show More