17 Commits
v1.6 ... v1.14

Author SHA1 Message Date
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
70 changed files with 4900 additions and 3110 deletions

View File

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

2
.gitignore vendored
View File

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

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **android-backup-gui** (1295 symbols, 3535 relationships, 112 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** (1614 symbols, 4022 relationships, 139 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** (1295 symbols, 3535 relationships, 112 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** (1614 symbols, 4022 relationships, 139 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.

208
README.md
View File

@@ -1,139 +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支持本地和远端仓库
- **构建体积优化** — Release APK 仅 11.8 MBProGuard/R8 full mode + shrinkResources + BouncyCastle PQC 移除)
- **远程后端** — 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)
```
远端同步基于内容大小比较,跳过同名等长文件;自动删除远端/本地过时文件
### 关键设计
- **导航栏索引** — 使用 `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 验证数据归档,校验失败回告
restic 通过 REST HTTP API 与本地桥通信,桥接器将请求翻译为 SMB/WebDAV 文件操作
无需本地 staging 仓库restic 直接读写远程存储。
## 构建
### 版本历史
|-|版本|更新内容|
|-|---:|--------|
| | v1.3 | 累积快照、AppResult 类型化错误、RootShell Mutex、kotlinx-serialization 迁移 |
| | v1.4 | APK 体积优化ProGuard/R8 + shrinkResources + 依赖裁剪Release APK 从 25 MB 降至 11.8 MB-52.8% |
| 版本 | 更新内容 |
|------|---------|
| 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(不压缩,适合开发调试)
# Debug APK
./gradlew assembleDebug
# Release APKProGuard/R8 混淆 + 资源裁剪 + 签名)
./gradlew assembleRelease
# Release APK需配置签名)
KEYSTORE_PASSWORD=<密码> KEY_PASSWORD=<密码> ./gradlew assembleRelease
```
> Release 构建需配置 `release.keystore` 签名文件;`librestic.so` 放在 `app/src/main/jniLibs/arm64-v8a/`
> 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 账户锁定,需在服务器上解锁

View File

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

View File

@@ -8,8 +8,6 @@ kover {
filters {
excludes {
classes(
// Generated/auto classes
"*.databinding.*",
"*.BuildConfig",
"*.R",
"*.R\$*"
@@ -26,11 +24,14 @@ android {
applicationId "com.example.androidbackupgui"
minSdk 24
targetSdk 34
versionCode 7
versionName "1.6"
versionCode 14
versionName "1.14"
}
buildFeatures {
viewBinding true
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
lint {
disable 'QueryAllPackagesPermission'
@@ -38,9 +39,9 @@ android {
signingConfigs {
release {
storeFile rootProject.file("app/release.keystore")
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "android"
storePassword System.getenv("KEYSTORE_PASSWORD")
keyAlias "release"
keyPassword System.getenv("KEY_PASSWORD") ?: "android"
keyPassword System.getenv("KEY_PASSWORD")
v1SigningEnabled true
v2SigningEnabled true
}
@@ -48,7 +49,11 @@ android {
buildTypes {
release {
if (rootProject.file("app/release.keystore").exists()) {
signingConfig signingConfigs.release
def ksPass = System.getenv("KEYSTORE_PASSWORD")
def kPass = System.getenv("KEY_PASSWORD")
if (ksPass != null && kPass != null) {
signingConfig signingConfigs.release
}
}
}
}
@@ -80,16 +85,24 @@ android {
}
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.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

View File

@@ -51,7 +51,7 @@
# --- jcifs-ng (SMB) keep class/member names for MD4Provider reflection ---
# --- 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 { *; }

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,7 @@ data class BackupConfig(
val backupObbData: Int = 1,
val backupMedia: Int = 0,
val backgroundAppsIgnore: Int = 0,
val backupUserId: Int = 0, // Android user ID (0=Owner)
// Custom paths
val customPath: List<String> = listOf(
@@ -75,9 +76,41 @@ data class BackupConfig(
val resticBackendDomain: String = "" // SMB domain (optional, for NTLM)
) {
companion object {
/**
* Unescape a quoted config value. Reverses [escapeValue]: turns \\ and \"
* back into \ and ". Applied only to values that were stored inside quotes.
*/
private fun unescapeValue(s: String): String {
if (s.indexOf('\\') < 0) return s
val sb = StringBuilder(s.length)
var i = 0
while (i < s.length) {
val c = s[i]
if (c == '\\' && i + 1 < s.length) {
sb.append(s[i + 1]); i += 2
} else {
sb.append(c); i++
}
}
return sb.toString()
}
/** Escape a value for safe storage inside double quotes. */
private fun escapeValue(s: String): String =
s.replace("\\", "\\\\").replace("\"", "\\\"")
fun fromFile(file: File): BackupConfig {
if (!file.exists()) return BackupConfig()
// Quoted-string fields preserve their inner whitespace and may contain
// escaped characters; bare fields are trimmed as before.
val quotedKeys = setOf(
"Output_path", "list_location", "mount_point",
"restic_repo", "restic_password", "restic_backend_url",
"restic_backend_user", "restic_backend_pass",
"restic_backend_share", "restic_backend_domain"
)
val props = mutableMapOf<String, String>()
file.forEachLine { line ->
val trimmed = line.trim()
@@ -85,8 +118,21 @@ 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
@@ -114,6 +160,7 @@ data class BackupConfig(
backupObbData = int("Backup_obb_data", default = 1),
backupMedia = int("backup_media"),
backgroundAppsIgnore = int("Background_apps_ignore"),
backupUserId = int("backup_user_id"),
customPath = lines("Custom_path"),
blacklistMode = int("blacklist_mode"),
blacklist = lines("blacklist"),
@@ -144,16 +191,17 @@ data class BackupConfig(
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("Output_path=\"${escapeValue(config.outputPath)}\"")
appendLine("list_location=\"${escapeValue(config.listLocation)}\"")
appendLine("update=${config.update}")
appendLine("cdn=${config.cdn}")
appendLine("mount_point=\"${config.mountPoint}\"")
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")}") }
@@ -174,15 +222,17 @@ data class BackupConfig(
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_repo=\"${escapeValue(config.resticRepo)}\"")
appendLine("restic_password=\"${escapeValue(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}\"")
appendLine("restic_backend_url=\"${escapeValue(config.resticBackendUrl)}\"")
appendLine("restic_backend_user=\"${escapeValue(config.resticBackendUser)}\"")
appendLine("restic_backend_pass=\"${escapeValue(config.resticBackendPass)}\"")
appendLine("restic_backend_share=\"${escapeValue(config.resticBackendShare)}\"")
appendLine("restic_backend_domain=\"${escapeValue(config.resticBackendDomain)}\"")
})
file.setReadable(true, true) // owner only
file.setWritable(true, true) // owner only
}
}
}

View File

@@ -254,7 +254,7 @@ object BackupOperation {
if (!archiveCreated) {
Log.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)")
return true
return false
}
// Verify compression integrity

View File

@@ -1,131 +0,0 @@
package com.example.androidbackupgui.backup
import android.util.Log
import org.bouncycastle.crypto.digests.MD4Digest
import java.security.MessageDigest
import java.security.MessageDigestSpi
import java.security.Provider
import java.security.Security
/**
* Ensures MD4 [MessageDigest] is available for jcifs-ng on Android.
*
* jcifs-ng 2.1.x obtains MD4 by instantiating [BouncyCastleProvider]
* and calling [MessageDigest.getInstance]("MD4", bcProvider).
* Android's BouncyCastleProvider class is shadowed by the boot classloader
* and lacks MD4.
*
* Strategy: use reflection to replace `jcifs.util.Crypto.provider`
* with a delegating provider that wraps Android's BC and adds MD4.
* The MD4 [MessageDigestSpi] implementation comes from [MD4Digest]
* in bcprov-jdk15to18 (not shadowed — the class is not in boot CL).
*/
object MD4Provider {
private const val TAG = "MD4Provider"
private val registered = java.util.concurrent.atomic.AtomicBoolean(false)
private val md4Provider: Provider by lazy {
val bc = Security.getProvider("BC")
Md4DelegatingProvider(bc)
}
fun register() {
if (!registered.compareAndSet(false, true)) return
try {
// 1. Replace cached provider in every jcifs-ng class that has one
setProviderField("jcifs.util.Crypto")
for (cn in listOf(
"jcifs.smb.NtlmUtil",
"jcifs.smb.NtlmPasswordAuthenticator",
"jcifs.ntlmssp.Type3Message",
"jcifs.smb.NtlmContext"
)) setProviderField(cn)
// 2. Verify by checking what Crypto.getProvider() returns
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})")
} catch (_: Exception) {}
// 3. Fallback: register a global MD4 provider too
try {
Security.insertProviderAt(Md4StandaloneProvider(), 1)
} catch (_: Exception) {}
} catch (e: Exception) {
Log.e(TAG, "Failed to inject MD4", 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, md4Provider)
Log.i(TAG, "Set $clsName.${f.name} = Md4DelegatingProvider")
return
}
}
Log.i(TAG, "No static Provider field in $clsName")
} catch (_: ClassNotFoundException) {
Log.i(TAG, "Class not found: $clsName")
}
}
// ── MD4 MessageDigestSpi ────────────────────────────────────
class Md4DigestSpi : 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() }
}
// ── Delegating provider ─────────────────────────────────────
/** A "BC"-named provider that delegates to [bc] except for MD4. */
private class Md4DelegatingProvider(
private val bc: Provider?
) : Provider("BC", bc?.version ?: 1.0, "BC + MD4") {
init {
// Register MD4 service in the provider's internal service map
putService(Service(this, "MessageDigest", "MD4",
Md4DigestSpi::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)
}
return bc?.getService(type, algorithm)
}
override fun getServices(): MutableSet<Service> {
val s = (bc?.getServices() ?: emptySet<Service>()).toMutableSet()
s.addAll(super.getServices())
return s
}
}
/** Standalone MD4-only provider registered globally as fallback. */
private class Md4StandaloneProvider : Provider("Md4Provider", 1.0, "MD4 only") {
override fun getService(type: String, algorithm: String): Service? {
if (type == "MessageDigest" && algorithm.equals("MD4", ignoreCase = true)) {
return Service(this, type, algorithm, Md4DigestSpi::class.java.name, null, null)
}
return null
}
}
}

View File

@@ -55,6 +55,14 @@ object MissingAlgoProvider {
} 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)
@@ -135,3 +143,18 @@ object MissingAlgoProvider {
}
}
}
/**
* 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

@@ -42,6 +42,8 @@ interface RemoteTransport {
suspend fun delete(remotePath: String): AppResult<Unit>
suspend fun exists(remotePath: String): AppResult<Boolean>
/** Get the size of a remote file in bytes. Returns [AppResult.Failure] if not found. */
suspend fun fileSize(remotePath: String): AppResult<Long>
companion object {
@@ -68,6 +70,3 @@ interface RemoteTransport {
}
}
/** Extension to check if an [AppError] represents a "not found" remote error. */
internal fun AppError.isFileNotFound(): Boolean =
this is AppError.Remote && this.isNotFound

View File

@@ -2,14 +2,15 @@ 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:
* ```kotlin
* bridgeRunner.withBridge(backend, url, user, pass, share, domain, repoPath) { bridgeUrl ->
* 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
@@ -47,25 +48,26 @@ class RestBridgeRunner {
share: String,
domain: String
) -> RemoteTransport? = ::createTransport,
block: suspend (bridgeUrl: String) -> T
block: suspend (bridgeUrl: String, authToken: String) -> T
): T {
if (backend == "local") {
return block(repoPath)
return block(repoPath, "")
}
// Reuse cached transport (same SMB session) for consistent cross-bridge visibility
val key = "$backend|$backendUrl|$backendUser|$backendShare|$backendDomain"
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)
?: return block(repoPath, "")
cachedTransport = t
cachedTransportKey = key
}
val transport = cachedTransport!!
val remoteBase = buildRemoteBase(backend, backendUrl, backendShare, repoPath)
val bridge = ResticRestBridge(transport, remoteBase, cacheDir)
val bridge = ResticRestBridge(transport, remoteBase, repoPath, cacheDir, authToken)
try {
bridge.start(0)
@@ -74,14 +76,13 @@ class RestBridgeRunner {
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")
return block(bridgeUrl)
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")
// Clean up any leftover blob temp files
val blobs = cacheDir.listFiles { f -> f.name.startsWith("restic_blob_") }
if (blobs != null) {
for (f in blobs) f.delete()

View File

@@ -4,6 +4,7 @@ import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import kotlin.coroutines.coroutineContext
import com.example.androidbackupgui.backup.AppError
import com.example.androidbackupgui.backup.AppResult
@@ -55,25 +56,25 @@ class ResticBackup(
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") emit(progress)
} catch (_: Exception) { }
} catch (e: Exception) { if (e is CancellationException) throw e }
}
if (result.exitCode != 0) return@withContext err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
parseBackupSummary(result.stdout)
} else {
bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl ->
bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl, authToken ->
val args = mutableListOf("backup", "--json")
for (path in paths) args.add(path)
for (tag in tags) { args.add("--tag"); args.add(tag) }
if (hostname != null) { args.add("--host"); args.add(hostname) }
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runResticStreaming(env, args) { line ->
if (!coroutineContext.isActive) return@runResticStreaming
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") emit(progress)
} catch (_: Exception) { }
} catch (e: Exception) { if (e is CancellationException) throw e }
}
if (result.exitCode != 0) return@withBridge err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
@@ -117,20 +118,20 @@ class ResticBackup(
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") emit(progress)
} catch (_: Exception) { }
} catch (e: Exception) { if (e is CancellationException) throw e }
}
if (result.exitCode != 0) return@withContext err(AppError.Restic("restic stream backup 失败", result.exitCode, result.stderr))
parseBackupSummary(result.stdout)
} else {
bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runResticWithStdin(env, args, stdinFile) { line ->
if (!coroutineContext.isActive) return@runResticWithStdin
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") emit(progress)
} catch (_: Exception) { }
} catch (e: Exception) { if (e is CancellationException) throw e }
}
if (result.exitCode != 0) return@withBridge err(AppError.Restic("restic stream backup 失败", result.exitCode, result.stderr))

View File

@@ -36,6 +36,22 @@ class ResticCommandRunner {
Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args -> cmd=$cmd")
}
/** Wait for process to exit with a polling loop (compatible with API 24+). */
private fun Process.waitForCompat(deadlineMs: Long = 60_000): Int {
val deadline = System.currentTimeMillis() + deadlineMs
while (System.currentTimeMillis() < deadline) {
try {
return exitValue()
} catch (_: IllegalThreadStateException) {
Thread.sleep(100)
}
}
Log.w(TAG, "process did not exit within ${deadlineMs}ms, destroying")
destroy()
waitFor()
return exitValue()
}
/** Run restic (non-streaming). */
fun runRestic(env: Map<String, String>, args: List<String>): CommandResult {
val cmdArgs = buildCommandArgs(args)
@@ -50,28 +66,23 @@ class ResticCommandRunner {
pb.redirectErrorStream(false)
val process = pb.start()
// Drain stderr on a separate daemon thread to avoid a pipe deadlock:
// if stderr's buffer fills while we're still reading stdout, the child
// process blocks on writing stderr and we block on reading stdout.
var stderrBytes = byteArrayOf()
val stderrThread = Thread {
try {
stderrBytes = process.errorStream.use { it.readAllBytesCompat() }
} catch (_: Exception) {
// stream closed early; leave stderrBytes empty
}
}.apply { isDaemon = true; start() }
val stdout = process.inputStream.bufferedReader().use(BufferedReader::readText)
val stderrBytes = process.errorStream.use { it.readAllBytesCompat() }
val exitCode = try {
val deadline = System.currentTimeMillis() + 60_000
var exited = false
while (System.currentTimeMillis() < deadline && !exited) {
try {
process.exitValue()
exited = true
} catch (_: IllegalThreadStateException) {
Thread.sleep(100)
}
}
if (exited) {
process.exitValue()
} else {
Log.w(TAG, "runRestic: process did not exit within 60s, destroying")
process.destroy()
process.waitFor()
process.exitValue()
}
process.waitForCompat()
} catch (_: Exception) { -1 }
try { stderrThread.join(1_000) } catch (_: InterruptedException) {}
val stderrText = stderrBytes.decodeToString()
Log.i(TAG, "runRestic exitCode=$exitCode stdout_len=${stdout.length}")
if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText.trim()}")
@@ -111,14 +122,15 @@ class ResticCommandRunner {
val reader = process.inputStream.bufferedReader()
try {
var line: String
while (reader.readLine().also { line = it } != null) {
var line = reader.readLine()
while (line != null) {
if (!coroutineContext.isActive) {
process.destroy()
break
}
stdoutText.appendLine(line)
onLine(line)
line = reader.readLine()
}
} finally {
try { reader.close() } catch (_: Exception) {}
@@ -126,25 +138,7 @@ class ResticCommandRunner {
val stderrBytes = try { process.errorStream.use { it.readAllBytesCompat() } } catch (_: Exception) { byteArrayOf() }
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}")
@@ -196,14 +190,15 @@ class ResticCommandRunner {
val reader = process.inputStream.bufferedReader()
try {
var line: String
while (reader.readLine().also { line = it } != null) {
var line = reader.readLine()
while (line != null) {
if (!coroutineContext.isActive) {
process.destroy()
break
}
stdoutText.appendLine(line)
onLine(line)
line = reader.readLine()
}
} finally {
try { reader.close() } catch (_: Exception) {}
@@ -211,24 +206,7 @@ class ResticCommandRunner {
val stderrBytes = try { process.errorStream.use { it.readAllBytesCompat() } } catch (_: Exception) { byteArrayOf() }
val stderrText = stderrBytes.decodeToString().trim()
val exitCode = try {
val deadline = System.currentTimeMillis() + 60_000
var exited = false
while (System.currentTimeMillis() < deadline && !exited) {
try {
process.exitValue()
exited = true
} catch (_: IllegalThreadStateException) {
Thread.sleep(100)
}
}
if (exited) {
process.exitValue()
} else {
Log.w(TAG, "runResticWithStdin: process did not exit within 60s, destroying")
process.destroy()
process.waitFor()
process.exitValue()
}
process.waitForCompat()
} catch (_: Exception) { -1 }
Log.i(TAG, "runResticWithStdin exitCode=$exitCode stdout_len=${stdoutText.length}")

View File

@@ -10,11 +10,16 @@ class ResticEnvResolver {
fun buildBridgeEnv(
password: String,
bridgeUrl: String,
cacheDir: String
cacheDir: String,
authToken: String = ""
): Map<String, String> {
val env = HashMap(System.getenv() ?: emptyMap())
env["RESTIC_REPOSITORY"] = bridgeUrl
env["RESTIC_PASSWORD"] = password
if (authToken.isNotEmpty()) {
env["RESTIC_REST_USERNAME"] = authToken
env["RESTIC_REST_PASSWORD"] = authToken
}
if (cacheDir.isNotEmpty()) {
env["HOME"] = cacheDir
env["XDG_CACHE_HOME"] = cacheDir

View File

@@ -52,8 +52,8 @@ class ResticMaintenance(
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, "prune")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr))
@@ -61,6 +61,36 @@ class ResticMaintenance(
}
}
// ── Unlock ──────────────────────────────────────────
suspend fun unlock(
repoPath: String,
password: String,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
): AppResult<String> =
withContext(Dispatchers.IO) {
if (backend == "local") {
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runRestic(env, "unlock")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic unlock 失败", result.exitCode, result.stderr))
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, "unlock")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic unlock 失败", result.exitCode, result.stderr))
}
}
}
// ── Check ──────────────────────────────────────────
suspend fun check(
@@ -82,8 +112,8 @@ class ResticMaintenance(
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, "check")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic check 失败", result.exitCode, result.stderr))
@@ -112,8 +142,8 @@ class ResticMaintenance(
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, "stats")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic stats 失败", result.exitCode, result.stderr))

View File

@@ -49,8 +49,8 @@ class ResticRepoInit(
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
runInit(env)
}
}

View File

@@ -1,12 +1,16 @@
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.
*
@@ -14,12 +18,18 @@ import java.util.UUID
* 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 cacheDir: File
) : NanoHTTPD(0) {
private val repoPath: String,
private val cacheDir: File,
private val authToken: String = ""
) : NanoHTTPD("127.0.0.1", 0) {
private val TAG = "ResticRestBridge"
@@ -34,6 +44,21 @@ class ResticRestBridge(
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 {
@@ -56,6 +81,14 @@ class ResticRestBridge(
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") {
@@ -71,7 +104,7 @@ class ResticRestBridge(
}
}
val segments = path.split("/").filter { it.isNotEmpty() }
val segments = strippedPath.split("/").filter { it.isNotEmpty() }
if (segments.isEmpty()) {
return newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Invalid path")
@@ -110,13 +143,39 @@ class ResticRestBridge(
* 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
tmpFile.outputStream().use { output -> input.copyTo(output) }
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) {
Log.w(TAG, "stream body to file failed", e)
val elapsed = System.currentTimeMillis() - started
Log.w(TAG, "streamBodyToFile failed after ${elapsed}ms", e)
Result.failure(e)
}
}
@@ -130,10 +189,15 @@ class ResticRestBridge(
val remotePath = "$remoteBase/config"
when (method) {
NanoHTTPD.Method.HEAD -> {
when (val result = transport.exists(remotePath)) {
when (val exists = transport.exists(remotePath)) {
is AppResult.Success -> {
if (result.data) {
newFixedLengthResponse(Response.Status.OK, "application/octet-stream", "")
if (exists.data) {
val sizeResult = transport.fileSize(remotePath)
val fileSize = if (sizeResult is AppResult.Success) sizeResult.data else 0L
newFixedLengthResponse(
Response.Status.OK, "application/octet-stream",
ByteArrayInputStream(ByteArray(0)), fileSize
)
} else {
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "")
}
@@ -148,7 +212,8 @@ class ResticRestBridge(
try {
when (transport.download(remotePath, tempFile.absolutePath)) {
is AppResult.Success -> {
newChunkedResponse(Response.Status.OK, "application/octet-stream", tempFile.inputStream())
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", ""
@@ -198,17 +263,12 @@ class ResticRestBridge(
}
}
@Serializable
data class BlobEntry(val name: String, val size: Long)
private fun buildV2Json(items: List<RemoteTransport.RemoteFileInfo>): String {
val sb = StringBuilder("[")
var first = true
for (item in items) {
if (item.isDirectory) continue
if (!first) sb.append(",")
first = false
sb.append("{\"name\":\"${item.name}\",\"size\":${item.size}}")
}
sb.append("]")
return sb.toString()
val blobs = items.filter { !it.isDirectory }.map { BlobEntry(it.name, it.size) }
return Json.encodeToString(blobs)
}
// -- Blob HEAD (exists + size) ----------------------------------
@@ -279,14 +339,14 @@ class ResticRestBridge(
response.addHeader("Content-Length", chunkSize.toString())
return@runBlocking response
}
// Full file — stream directly without loading into memory
// Full file — read into memory (blobs are typically small)
val data = tempFile.readBytes()
val response = newChunkedResponse(
Response.Status.OK,
"application/octet-stream",
tempFile.inputStream()
data.inputStream()
)
response.addHeader("Content-Length", tempFile.length().toString())
response.addHeader("Content-Length", data.size.toString())
response
}
is AppResult.Failure -> newFixedLengthResponse(

View File

@@ -3,6 +3,7 @@ package com.example.androidbackupgui.backup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import java.io.File
import kotlin.coroutines.coroutineContext
import com.example.androidbackupgui.backup.AppError
@@ -75,7 +76,7 @@ class ResticRestore(
emit("恢复完成: ${progress.totalFiles} 个文件")
}
}
} catch (_: Exception) { emit(line) }
} catch (e: Exception) { if (e is CancellationException) throw e; emit(line) }
}
if (result.exitCode == 0) AppResult.Success(Unit)
@@ -84,13 +85,13 @@ class ResticRestore(
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare, backendDomain,
repoPath, File(cacheDir)
) { bridgeUrl ->
) { bridgeUrl, authToken ->
File(targetPath).mkdirs()
val args = mutableListOf("restore", snapshotId, "--target", targetPath, "--json")
if (include != null) { args.add("--include"); args.add(include) }
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runResticStreaming(env, args) { line ->
if (!coroutineContext.isActive) return@runResticStreaming
try {
@@ -104,7 +105,7 @@ class ResticRestore(
emit("恢复完成: ${progress.totalFiles} 个文件")
}
}
} catch (_: Exception) { emit(line) }
} catch (e: Exception) { if (e is CancellationException) throw e; emit(line) }
}
if (result.exitCode == 0) AppResult.Success(Unit)
@@ -142,8 +143,8 @@ class ResticRestore(
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare, backendDomain,
repoPath, File(cacheDir)
) { bridgeUrl ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, "dump", snapshotId, filePath)
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))

View File

@@ -65,11 +65,11 @@ class ResticSnapshotOps(
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl ->
) { bridgeUrl, authToken ->
val args = mutableListOf("snapshots", "--json")
if (tag != null) { args.add("--tag"); args.add(tag) }
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, args)
if (result.exitCode != 0) {
@@ -121,7 +121,7 @@ class ResticSnapshotOps(
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl ->
) { bridgeUrl, authToken ->
val args = mutableListOf(
"forget",
"--keep-daily", keepDaily.toString(),
@@ -130,7 +130,7 @@ class ResticSnapshotOps(
)
if (dryRun) args.add("--dry-run")
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, args)
if (result.exitCode == 0) AppResult.Success(result.stdout)

View File

@@ -359,6 +359,20 @@ object ResticWrapper {
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. */

View File

@@ -7,7 +7,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
@@ -82,7 +82,7 @@ object RestoreOperation {
val failAtomic = AtomicInteger(0)
val semaphore = Semaphore(2)
coroutineScope {
supervisorScope {
packages.forEachIndexed { index, pkg ->
launch {
if (!coroutineContext.isActive) return@launch
@@ -298,11 +298,7 @@ object RestoreOperation {
if (!result.isSuccess) return false
return !result.output.lines().any { line ->
val path = line.substringBefore(" -> ")
val hasTraversal = path.trimStart('/').split("/").any { segment -> segment == ".." }
val symlinkTarget = if (" -> " in line) line.substringAfter(" -> ") else ""
val unsafeSymlink = symlinkTarget.isNotEmpty() &&
(symlinkTarget.startsWith("/") || symlinkTarget.split("/").any { segment -> segment == ".." })
hasTraversal || unsafeSymlink
path.trimStart('/').split("/").any { segment -> segment == ".." }
}
}
@@ -348,6 +344,14 @@ object RestoreOperation {
val ssaidValue = ssaidFile.readText().trim()
if (ssaidValue.isBlank()) 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
@@ -375,7 +379,8 @@ object RestoreOperation {
// Generate a UUID for the new entry
val uuidResult = RootShell.exec("cat /proc/sys/kernel/random/uuid 2>/dev/null")
val id = uuidResult.output.trim()
if (id.length != 36) { // UUID format check
// 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
}
@@ -432,8 +437,9 @@ object RestoreOperation {
val pkgEsc = packageName.shellEscape()
// Reset app ops first (clears any previous modes)
RootShell.exec("appops reset '$pkgEsc' 2>/dev/null")
// NOTE: Intentionally skipping "appops reset" because we don't capture
// app ops state (battery optimization, notification settings, etc.)
// in the backup. Resetting would lose those user customizations.
val grantedPerms = parsedPerms.filter { it.second }.map { it.first }
val deniedPerms = parsedPerms.filter { !it.second }.map { it.first }

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

@@ -70,55 +70,53 @@ class SmbTransport(
private fun smbFile(path: String): SmbFile = SmbFile(buildUrl(path), context)
override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
withContext(Dispatchers.IO) {
try {
val localFile = File(localPath)
val remote = smbFile(remotePath)
// Ensure parent directories exist (parent can be null at share root)
val parentPath = remote.parent
if (parentPath != null) {
val parent = SmbFile(parentPath, context)
if (!parent.exists()) parent.mkdirs()
}
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
val fileSize = localFile.length()
SmbFileOutputStream(remote).use { output ->
localFile.inputStream().use { input ->
onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath))
val buffer = ByteArray(bufferSize)
var totalRead = 0L
var n = input.read(buffer)
while (n != -1) {
output.write(buffer, 0, n)
totalRead += n
onByteProgress(RemoteTransport.ByteProgress(totalRead, fileSize, remotePath))
n = input.read(buffer)
retryWithBackoff(TAG, "SMB 上传") {
withContext(Dispatchers.IO) {
try {
val localFile = File(localPath)
val remote = smbFile(remotePath)
val parentPath = remote.parent
if (parentPath != null) {
val parent = SmbFile(parentPath, context)
if (!parent.exists()) parent.mkdirs()
}
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
val fileSize = localFile.length()
SmbFileOutputStream(remote).use { output ->
localFile.inputStream().use { input ->
onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath))
val buffer = ByteArray(bufferSize)
var totalRead = 0L
var n = input.read(buffer)
while (n != -1) {
output.write(buffer, 0, n)
totalRead += n
onByteProgress(RemoteTransport.ByteProgress(totalRead, fileSize, remotePath))
n = input.read(buffer)
}
}
}
val freshRemote = SmbFile(buildUrl(remotePath), context)
val actualSize = freshRemote.length()
Log.i(TAG, "upload done: $fileSize bytes local, $actualSize bytes on SMB")
if (actualSize != fileSize) {
Log.e(TAG, "upload size mismatch: local=$fileSize smb=$actualSize")
return@withContext err(AppError.Remote("SMB 上传大小不匹配", "upload"))
}
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "upload failed: ${buildUrl(remotePath)}", e)
err(AppError.Remote("SMB 上传失败", "upload", cause = e))
}
// Re-read with a fresh SmbFile handle to verify (jcifs-ng may have stale handle)
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.w(TAG, "upload size mismatch: local=$fileSize smb=$actualSize")
// Try re-opening the output stream to flush any pending writes
SmbFileOutputStream(remote).use { it.write(ByteArray(0)) }
val retrySize = freshRemote.length()
Log.w(TAG, "upload retry: smb=$retrySize bytes")
}
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))
}
}
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
withContext(Dispatchers.IO) {
retryWithBackoff(TAG, "SMB 下载") {
withContext(Dispatchers.IO) {
try {
val localFile = File(localPath)
localFile.parentFile?.mkdirs()
@@ -149,6 +147,7 @@ class SmbTransport(
err(AppError.Remote("SMB 下载失败", "download", cause = e))
}
}
}
override suspend fun listFiles(remoteDir: String): AppResult<List<RemoteTransport.RemoteFileInfo>> =
withContext(Dispatchers.IO) {
@@ -253,4 +252,17 @@ class SmbTransport(
err(AppError.Remote("SMB 检查失败", "exists", cause = e))
}
}
override suspend fun fileSize(remotePath: String): AppResult<Long> =
withContext(Dispatchers.IO) {
try {
val file = smbFile(remotePath)
if (!file.exists()) return@withContext err(AppError.Remote("文件不存在", "fileSize"))
AppResult.Success(file.length())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
err(AppError.Remote("SMB 获取文件大小失败", "fileSize", cause = e))
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,66 @@
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.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.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.CONFIG -> ConfigScreen(snackbarHostState = snackbarHostState)
}
}
}
}

View File

@@ -1,547 +0,0 @@
package com.example.androidbackupgui.ui
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import androidx.core.content.ContextCompat
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.androidbackupgui.backup.AppInfo
import com.example.androidbackupgui.backup.PackageName
import com.example.androidbackupgui.backup.AppScanner
import com.example.androidbackupgui.backup.BackupConfig
import com.example.androidbackupgui.backup.BackupOperation
import com.example.androidbackupgui.backup.BackupService
import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.ResticWrapper
import com.example.androidbackupgui.backup.WifiManager
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.databinding.FragmentBackupBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import android.os.StatFs
import com.example.androidbackupgui.backup.StreamingBackup
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import com.example.androidbackupgui.backup.formatSize
import java.io.File
import java.util.Locale
class BackupFragment : Fragment() {
private var _binding: FragmentBackupBinding? = null
private val binding get() = _binding!!
private var apps: List<AppInfo> = emptyList()
private var selectedApps = mutableSetOf<String>()
private var sortedApps: List<AppInfo> = emptyList()
private lateinit var config: BackupConfig
private var selectedUserId: Int = 0
private var userList: List<Pair<Int, String>> = listOf(0 to "Owner")
private var sortMode: SortMode = SortMode.NAME_ASC
private var showSystemApps: Boolean = false
private var excludeDataFromBackup = mutableSetOf<String>()
private enum class SortMode { NAME_ASC, SIZE_DESC }
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentBackupBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val configFile = File(requireContext().filesDir, "backup_settings.conf")
config = BackupConfig.fromFile(configFile)
updateOutputPathDisplay()
binding.appList.layoutManager = LinearLayoutManager(requireContext())
binding.scanButton.setOnClickListener { scanApps() }
binding.outputPathEdit.setOnClickListener { showOutputPathEditDialog() }
binding.backupButton.setOnClickListener { startBackup() }
// Sort/filter controls
binding.sortAZButton.setOnClickListener {
sortMode = SortMode.NAME_ASC
applySortFilter()
}
binding.sortSizeButton.setOnClickListener {
sortMode = SortMode.SIZE_DESC
applySortFilter()
}
binding.selectAllButton.setOnClickListener {
selectedApps.addAll(apps.map { it.packageName.value })
applySortFilter()
}
binding.deselectAllButton.setOnClickListener {
selectedApps.clear()
applySortFilter()
}
binding.showSystemSwitch.setOnCheckedChangeListener { _, checked ->
showSystemApps = checked
applySortFilter()
}
// Load user profiles and setup dropdown
loadUsers()
}
private fun loadUsers() {
viewLifecycleOwner.lifecycleScope.launch {
try {
userList = AppScanner.enumerateUsers()
val names = userList.map { (id, name) -> "$name (ID: $id)" }
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.userSelector.adapter = adapter
binding.userSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedUserId = userList.getOrNull(position)?.first ?: 0
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
} catch (e: Exception) {
binding.statusText.text = "加载用户失败: ${e.message}"
}
}
}
override fun onResume() {
super.onResume()
if (::config.isInitialized) {
val configFile = File(requireContext().filesDir, "backup_settings.conf")
config = BackupConfig.fromFile(configFile)
updateOutputPathDisplay()
}
}
private fun scanApps() {
binding.backupButton.isEnabled = false
setRunning(true)
binding.statusText.text = "正在扫描应用…"
viewLifecycleOwner.lifecycleScope.launch {
try {
val ctx = requireContext()
val thirdParty = AppScanner.scanThirdParty(ctx, userId = selectedUserId)
val system = AppScanner.scanSystem(ctx, config, userId = selectedUserId)
apps = if (showSystemApps) thirdParty + system else thirdParty
selectedApps.clear()
selectedApps.addAll(apps.map { it.packageName.value })
binding.statusText.text = "共找到 ${apps.size} 个应用,全部已选中"
binding.backupButton.isEnabled = apps.isNotEmpty()
setRunning(false)
applySortFilter()
} catch (e: Exception) {
binding.statusText.text = "扫描应用失败: ${e.message}"
setRunning(false)
binding.backupButton.isEnabled = false
}
}
}
private fun applySortFilter() {
var filtered = if (showSystemApps) apps else apps.filter { !it.isSystem }
filtered = when (sortMode) {
SortMode.NAME_ASC -> filtered.sortedBy { it.label.lowercase(Locale.US) }
SortMode.SIZE_DESC -> filtered.sortedByDescending { it.backupSize }
}
sortedApps = filtered
setupAppList()
binding.statusText.text = "已选择 ${selectedApps.size}/${sortedApps.size} 个应用"
}
private fun setupAppList() {
val displayApps = sortedApps.ifEmpty { apps }
binding.appList.adapter = PackageListAdapter(
displayApps, selectedApps,
onToggle = { pkg, checked ->
if (checked) selectedApps.add(pkg) else selectedApps.remove(pkg)
binding.statusText.text = "已选择 ${selectedApps.size}/${displayApps.size} 个应用"
},
excludeDataFrom = excludeDataFromBackup,
onExcludeDataToggle = { pkg, excluded ->
if (excluded) excludeDataFromBackup.add(pkg) else excludeDataFromBackup.remove(pkg)
}
)
}
private fun startBackup() {
val toBackup = apps.filter { it.packageName.value in selectedApps }
if (toBackup.isEmpty()) return
setRunning(true)
binding.backupButton.isEnabled = false
binding.scanButton.isEnabled = false
// Start foreground service to keep process alive
val serviceIntent = Intent(requireContext(), BackupService::class.java)
serviceIntent.action = BackupService.ACTION_START_BACKUP
serviceIntent.putExtra(BackupService.EXTRA_STATUS_TEXT, "正在备份 ${toBackup.size} 个应用…")
try {
ContextCompat.startForegroundService(requireContext(), serviceIntent)
} catch (_: Exception) {}
viewLifecycleOwner.lifecycleScope.launch {
try {
val outputDir = File(config.outputPath.ifEmpty {
requireContext().filesDir.absolutePath
})
// ── Restic pre-flight: load snapshot metadata for cumulative merge ──
var snapshotApps: Map<String, ResticWrapper.SnapshotAppInfo>? = null
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
updateStatus("正在检查 restic 历史快照…")
if (config.resticBackend == "local" && !File(config.resticRepo, "config").exists()) {
updateStatus("restic 本地仓库未初始化,请先在设置中初始化")
return@launch
}
val binaryPath = ResticBinary.prepare(requireContext())
if (binaryPath != null) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.cacheDir = requireContext().cacheDir.absolutePath
ResticWrapper.backendDomain = config.resticBackendDomain
snapshotApps = ResticWrapper.getLatestSnapshotAppDetails(
repoPath = config.resticRepo,
password = config.resticPassword,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare
)
if (snapshotApps != null) {
updateStatus("发现历史快照,将合并为累积备份")
}
}
}
// ── Build merged app list for cumulative snapshot ──
val selectedPkgs = toBackup.map { it.packageName.value }.toSet()
val allApps: List<AppInfo>
val includePkgs: Set<String>
if (snapshotApps != null) {
// Create placeholder AppInfo entries for packages from the snapshot
// that are NOT in the current selection. These won't be re-backed-up
// but their metadata is preserved via legacyApps.
val snapshotOnly = snapshotApps.keys.filter { it !in selectedPkgs }
val legacyEntries = snapshotOnly.mapNotNull { pkg ->
val snap = snapshotApps[pkg] ?: return@mapNotNull null
AppInfo(
packageName = PackageName(pkg),
label = snap.label,
isSystem = snap.isSystem
)
}
allApps = toBackup + legacyEntries
includePkgs = selectedPkgs
val snapCount = legacyEntries.size
if (snapCount > 0) {
updateStatus("累积备份: ${allApps.size} 个应用 ($snapCount 个来自历史快照)")
}
// Restore latest snapshot to populate directories for unchanged apps
updateStatus("正在恢复历史快照…")
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_${selectedUserId}")
backupRoot.mkdirs()
val snapsResult = ResticWrapper.listSnapshots(
repoPath = config.resticRepo,
password = config.resticPassword,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare
)
val latestSnap = (snapsResult as? AppResult.Success)?.data?.firstOrNull()
if (latestSnap != null) {
ResticWrapper.restore(
repoPath = config.resticRepo,
password = config.resticPassword,
snapshotId = latestSnap.shortId,
targetPath = backupRoot.absolutePath,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare
)
}
} else {
allApps = toBackup
includePkgs = emptySet()
}
// ── Execute backup (with cumulative metadata) ──
updateStatus("正在备份: ${allApps.size} 个应用…")
val result = BackupOperation.backupApps(
context = requireContext(),
apps = allApps,
config = config,
outputDir = outputDir,
userId = selectedUserId.toString(),
noDataBackup = excludeDataFromBackup.toSet(),
includePkgs = includePkgs,
legacyApps = snapshotApps,
onProgress = { progress ->
val label = allApps.find { it.packageName.value == progress.packageName }?.label
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
updateStatus("[${progress.current}/${progress.total}] $name: ${progress.message}")
}
)
// Store WiFi config inside Backup_* directory so restic/local restore can find it
WifiManager.backup(File(result.outputDir))
// If restic is enabled, snapshot to repository
var resticSummary: ResticWrapper.BackupSummary? = null
var resticError: String? = null
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
val binaryPath = ResticBinary.prepare(requireContext())
if (binaryPath != null) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.cacheDir = requireContext().cacheDir.absolutePath
ResticWrapper.backendDomain = config.resticBackendDomain
if (config.resticBackend == "local") {
if (!File(config.resticRepo, "config").exists()) {
updateStatus("restic 本地仓库未初始化,请先在设置中初始化")
return@launch
}
}
updateStatus("正在写入 restic 去重仓库…")
val resticResult = ResticWrapper.backup(
repoPath = config.resticRepo,
password = config.resticPassword,
paths = listOf(result.outputDir),
tags = listOf("backup_${System.currentTimeMillis() / 1000}"),
hostname = "android-backup-gui",
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onProgress = { progress ->
if (progress.messageType == "status") {
updateStatus("去重仓库: %.0f%% (%d/%d 个文件)".format(
progress.percentDone * 100,
progress.filesDone,
progress.totalFiles
))
}
}
)
when (resticResult) {
is AppResult.Success -> resticSummary = resticResult.data
is AppResult.Failure -> {
resticError = resticResult.error.message
updateStatus("restic 快照失败: ${resticResult.error.message}")
}
}
}
}
updateStatus(buildString {
appendLine("备份完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
appendLine("耗时: ${result.elapsedMs / 1000}")
appendLine("输出: ${result.outputDir}")
appendLine("模式: 累积快照")
val summary = resticSummary
if (summary != null) {
appendLine()
appendLine("── Restic 快照 ──")
appendLine("ID: ${summary.snapshotId.take(8)}")
appendLine("新增: ${summary.dataAdded / 1024 / 1024} MB")
appendLine("文件: ${summary.totalFilesProcessed}")
} else {
val err = resticError
if (err != null) {
appendLine()
appendLine("── Restic 错误 ──")
appendLine(err)
}
}
})
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
updateStatus("备份异常: ${e.message}")
} finally {
setRunning(false)
binding.backupButton.isEnabled = true
binding.scanButton.isEnabled = true
// Stop foreground service
try {
val stopIntent = Intent(requireContext(), BackupService::class.java)
stopIntent.action = BackupService.ACTION_STOP_BACKUP
requireContext().startService(stopIntent)
} catch (_: Exception) {}
}
}
}
private fun setRunning(running: Boolean) {
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
}
private suspend fun updateStatus(text: String) {
withContext(Dispatchers.Main) { binding.statusText.text = text }
}
private fun updateOutputPathDisplay() {
val path = config.outputPath.ifEmpty { requireContext().filesDir.absolutePath }
binding.outputPathLabel.text = path
}
private fun showOutputPathEditDialog() {
val editText = android.widget.EditText(requireContext()).apply {
setText(config.outputPath)
hint = requireContext().filesDir.absolutePath
}
com.google.android.material.dialog.MaterialAlertDialogBuilder(requireContext())
.setTitle("修改输出目录")
.setView(editText)
.setPositiveButton("确定") { _, _ ->
val newPath = editText.text.toString().trim()
config = config.copy(outputPath = newPath)
BackupConfig.toFile(config, File(requireContext().filesDir, "backup_settings.conf"))
updateOutputPathDisplay()
}
.setNegativeButton("取消", null)
.show()
}
// ── Space detection & streaming backup ────────────
/**
* Estimate the total size of data to back up using `du -sb`.
* Only counts data directories (not APKs) since that's the bulk.
*/
private suspend fun estimateBackupSize(apps: List<com.example.androidbackupgui.backup.AppInfo>): Long = withContext(Dispatchers.IO) {
var total = 0L
for (app in apps) {
val pkgEsc = app.packageName.value.shellEscape()
val result = RootShell.exec("du -sb /data/data/$pkgEsc 2>/dev/null | cut -f1")
val size = result.output.trim().toLongOrNull() ?: 0L
total += size
}
total
}
/**
* Check if [path] has at least [neededBytes] bytes free.
* Uses [StatFs] to query the filesystem.
*/
private fun hasEnoughSpace(path: File, neededBytes: Long): Boolean {
try {
val stat = StatFs(path.absolutePath)
val available = stat.availableBlocksLong * stat.blockSizeLong
// Require 1.5x headroom for temp files and metadata
return available >= neededBytes * 3 / 2
} catch (_: Exception) {
// If we can't check, assume enough space (staging mode)
return true
}
}
/**
* Run streaming backup via [StreamingBackup] + [ResticWrapper.backupStdin].
* Used when staging space is insufficient.
*/
@Suppress("UNUSED_PARAMETER")
private suspend fun runStreamingResticBackup(
config: com.example.androidbackupgui.backup.BackupConfig,
apps: List<com.example.androidbackupgui.backup.AppInfo>,
outputDir: File,
cacheDir: String
): ResticWrapper.BackupSummary? {
updateStatus("空间不足,启动流式备份模式…")
val cacheDirFile = File(cacheDir, "streaming_tmp")
cacheDirFile.mkdirs()
// Prepare streaming: create FIFO, metadata, collect APK paths
val streamingResult = StreamingBackup.prepareStreaming(
cacheDirFile, apps, null
)
// Start restic with stdin from FIFO, in parallel with data producer
var summary: ResticWrapper.BackupSummary? = null
var backupError: String? = null
coroutineScope {
// Launch restic backup (consumer)
val resticJob = async {
val result = ResticWrapper.backupStdin(
repoPath = config.resticRepo,
password = config.resticPassword,
stdinFile = streamingResult.dataFifo,
extraPaths = streamingResult.apkPaths + streamingResult.metaDir.absolutePath,
tags = listOf("streaming_${System.currentTimeMillis() / 1000}"),
hostname = "android-backup-gui",
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onProgress = { progress ->
if (progress.messageType == "status") {
updateStatus("流式去重仓库: %.0f%% (%d/%d 个文件)".format(
progress.percentDone * 100,
progress.filesDone,
progress.totalFiles
))
}
}
)
when (result) {
is AppResult.Success -> summary = result.data
is AppResult.Failure -> backupError = result.error.message
}
}
// Launch data producer (writes tar to FIFO)
val producerJob = async {
StreamingBackup.launchDataProducer(
apps = apps,
noDataBackup = excludeDataFromBackup.toSet(),
userId = selectedUserId.toString(),
fifoPath = streamingResult.dataFifo.absolutePath
)
}
// Wait for both to complete
producerJob.await()
resticJob.await()
}
// Cleanup FIFO
try { streamingResult.dataFifo.delete() } catch (_: Exception) {}
try { streamingResult.metaDir.deleteRecursively() } catch (_: Exception) {}
if (backupError != null) {
updateStatus("流式备份失败: $backupError")
}
return summary
}
}

View File

@@ -0,0 +1,338 @@
package com.example.androidbackupgui.ui
import android.content.Intent
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.core.content.ContextCompat
import com.example.androidbackupgui.backup.*
import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_START_BACKUP
import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_STOP_BACKUP
import com.example.androidbackupgui.backup.BackupService.Companion.EXTRA_STATUS_TEXT
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.WifiManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Locale
private enum class SortMode { NAME_ASC, SIZE_DESC }
@Composable
fun BackupScreen() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// ── State ──
var config by remember { mutableStateOf(BackupConfig()) }
var allApps by remember { mutableStateOf<List<AppInfo>>(emptyList()) }
var sortedApps by remember { mutableStateOf<List<AppInfo>>(emptyList()) }
var selectedApps by remember { mutableStateOf<Set<String>>(emptySet()) }
var excludeDataFromBackup by remember { mutableStateOf<Set<String>>(emptySet()) }
var sortMode by remember { mutableStateOf(SortMode.NAME_ASC) }
var showSystemApps by remember { mutableStateOf(false) }
var statusText by remember { mutableStateOf("请先扫描应用") }
var isRunning by remember { mutableStateOf(false) }
var isScanning by remember { mutableStateOf(false) }
// Load config
LaunchedEffect(Unit) {
config = BackupConfig.fromFile(File(context.filesDir, "backup_settings.conf"))
}
// Re-apply sort/filter when dependencies change
LaunchedEffect(allApps, sortMode, showSystemApps) {
val filtered = if (showSystemApps) allApps else allApps.filter { !it.isSystem }
val sorted = when (sortMode) {
SortMode.NAME_ASC -> filtered.sortedBy { it.label.lowercase(Locale.US) }
SortMode.SIZE_DESC -> filtered.sortedByDescending { it.backupSize }
}
sortedApps = sorted
}
Column(modifier = Modifier.fillMaxSize()) {
// ── Top controls card ──
Card(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Scan button
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
isScanning = true
statusText = "正在扫描应用…"
scope.launch {
try {
val userId = config.backupUserId
val thirdParty = withContext(Dispatchers.IO) {
AppScanner.scanThirdParty(context, userId = userId)
}
val system = withContext(Dispatchers.IO) {
AppScanner.scanSystem(context, config, userId = userId)
}
val apps = if (showSystemApps) thirdParty + system else thirdParty
allApps = apps
selectedApps = apps.map { it.packageName.value }.toSet()
statusText = "共找到 ${apps.size} 个应用,全部已选中"
} catch (e: Exception) {
statusText = "扫描应用失败: ${e.message}"
} finally {
isScanning = false
}
}
},
enabled = !isScanning && !isRunning,
modifier = Modifier.weight(1f)
) {
if (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 = sortMode == SortMode.NAME_ASC,
onClick = {
sortMode = SortMode.NAME_ASC
},
label = { Text("A-Z") },
leadingIcon = {
Icon(Icons.Default.SortByAlpha, contentDescription = null, modifier = Modifier.size(16.dp))
}
)
FilterChip(
selected = sortMode == SortMode.SIZE_DESC,
onClick = {
sortMode = SortMode.SIZE_DESC
},
label = { Text("大小") },
leadingIcon = {
Icon(Icons.Default.Storage, contentDescription = null, modifier = Modifier.size(16.dp))
}
)
Spacer(Modifier.width(8.dp))
TextButton(onClick = {
selectedApps = sortedApps.map { it.packageName.value }.toSet()
}) { Text("全选") }
TextButton(onClick = { selectedApps = emptySet() }) { Text("取消全选") }
}
// Show system switch
Row(verticalAlignment = Alignment.CenterVertically) {
Text("显示系统应用", modifier = Modifier.weight(1f))
Switch(checked = showSystemApps, onCheckedChange = { showSystemApps = it })
}
}
}
// ── 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(sortedApps, key = { it.packageName.value }) { app ->
AppListItem(
app = app,
isSelected = app.packageName.value in selectedApps,
isDataExcluded = app.packageName.value in excludeDataFromBackup,
onToggle = { checked ->
selectedApps = if (checked) selectedApps + app.packageName.value
else selectedApps - app.packageName.value
},
onExcludeDataToggle = { excluded ->
excludeDataFromBackup = if (excluded) excludeDataFromBackup + app.packageName.value
else excludeDataFromBackup - app.packageName.value
}
)
}
}
// ── Bottom bar with backup button ──
Surface(
modifier = Modifier.fillMaxWidth(),
tonalElevation = 3.dp
) {
Button(
onClick = {
val toBackup = allApps.filter { it.packageName.value in selectedApps }
if (toBackup.isEmpty()) return@Button
isRunning = true
statusText = "开始备份 ${toBackup.size} 个应用…"
scope.launch {
try {
// 1. Start foreground service
val serviceIntent = Intent(context, BackupService::class.java).apply {
action = ACTION_START_BACKUP
putExtra(EXTRA_STATUS_TEXT, "正在备份 ${toBackup.size} 个应用…")
}
try {
ContextCompat.startForegroundService(context, serviceIntent)
} catch (_: Exception) {}
// 2. Execute backup
val outputDir = File(config.outputPath.ifEmpty {
context.filesDir.absolutePath
})
val backupResult = withContext(Dispatchers.IO) {
BackupOperation.backupApps(
context = context,
apps = toBackup,
config = config,
outputDir = outputDir,
userId = config.backupUserId.toString(),
noDataBackup = excludeDataFromBackup,
onProgress = { progress ->
statusText = "[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
}
)
}
statusText = "备份完成!成功: ${backupResult.successCount} 失败: ${backupResult.failCount} 耗时: ${backupResult.elapsedMs / 1000}s"
// 3. WiFi 备份
WifiManager.backup(File(backupResult.outputDir))
// 4. Restic 上传(如启用)
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
val binaryPath = ResticBinary.prepare(context)
if (binaryPath != null) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.cacheDir = context.cacheDir.absolutePath
ResticWrapper.backendDomain = config.resticBackendDomain
statusText = "正在写入 restic 去重仓库…"
val resticResult = withContext(Dispatchers.IO) {
ResticWrapper.backup(
repoPath = config.resticRepo,
password = config.resticPassword,
paths = listOf(backupResult.outputDir),
tags = listOf("backup_${System.currentTimeMillis() / 1000}"),
hostname = "android-backup-gui",
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onProgress = { progress ->
if (progress.messageType == "status") {
statusText = "去重仓库: %.0f%% (%d/%d 个文件)".format(
progress.percentDone * 100,
progress.filesDone,
progress.totalFiles
)
}
}
)
}
when (resticResult) {
is AppResult.Success -> {
val summary = resticResult.getOrNull()
statusText = buildString {
appendLine("备份完成!")
appendLine("成功: ${backupResult.successCount} 失败: ${backupResult.failCount}")
appendLine("耗时: ${backupResult.elapsedMs / 1000}")
appendLine("Restic ID: ${summary?.snapshotId?.take(8)}")
if (summary != null) {
appendLine("新增: ${summary.dataAdded / 1024 / 1024} MB")
}
}
}
is AppResult.Failure -> {
statusText = "restic 快照失败: ${resticResult.errorOrNull()?.message}"
}
}
}
}
} catch (e: Exception) {
statusText = "备份异常: ${e.message}"
} finally {
isRunning = false
try {
val stopIntent = Intent(context, BackupService::class.java).apply {
action = ACTION_STOP_BACKUP
}
context.startService(stopIntent)
} catch (_: Exception) {}
}
}
},
enabled = !isRunning && selectedApps.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(12.dp)
) {
if (isRunning) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
}
Text("开始备份 (${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

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

View File

@@ -0,0 +1,464 @@
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) }
// 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
}
// 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 -> "配置导出失败"
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)
}
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 })
}
OutlinedTextField(
value = outputPath,
onValueChange = { outputPath = it },
label = { Text("输出目录") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
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
)
}
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,
)
)
},
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Filled.Save, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("保存配置")
}
// ── Export config button ──
OutlinedButton(
onClick = { exportLauncher.launch("backup_settings.conf") },
modifier = Modifier.fillMaxWidth()
) {
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
)

View File

@@ -16,9 +16,10 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
@@ -45,7 +46,9 @@ 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. */
@@ -69,6 +72,8 @@ sealed interface OperationEvent {
data object PruneStarted : OperationEvent
data object PruneFailed : OperationEvent
data object PruneCompleted : OperationEvent
data object ConfigExported : OperationEvent
data object ConfigExportFailed : OperationEvent
}
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
@@ -109,6 +114,14 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
/** 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)
@@ -145,7 +158,55 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
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 = "配置导出失败")) }
}
}
}
@@ -190,21 +251,12 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch {
try {
_operationEvents.emit(OperationEvent.InitStarted)
val result = withTimeoutOrNull(15 * 60 * 1000L) {
ResticWrapper.init(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
)
}
if (result == null) {
_operationEvents.emit(OperationEvent.InitFailed)
Log.w(TAG, "initResticRepo timed out after 15 minutes")
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "初始化超时15分钟请检查网络/SMB 服务器是否正常"
))}
refreshResticStatus(form)
} else if (result.isSuccess) {
val result = ResticWrapper.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}"
@@ -244,7 +296,9 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "正在检测仓库状态…")) }
viewModelScope.launch {
// Cancel any stale status check so a slow old coroutine doesn't overwrite new results
refreshJob?.cancel()
refreshJob = viewModelScope.launch {
val snapshotsResult = ResticWrapper.listSnapshots(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
@@ -255,16 +309,68 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
_uiState.update { it.copy(resticStatus = ResticStatus(
message = "仓库就绪,${snapshots.size} 个快照",
snapshotCount = snapshots.size,
initButtonVisible = false, statsButtonVisible = true, pruneButtonVisible = true
initButtonVisible = false, statsButtonVisible = true, pruneButtonVisible = true,
unlockButtonVisible = true
))}
} else {
_uiState.update { it.copy(resticStatus = ResticStatus(
message = "仓库未初始化或认证失败",
initButtonVisible = true, statsButtonVisible = false, pruneButtonVisible = false
))}
val errMsg = snapshotsResult.errorOrNull()?.message ?: ""
val hasLock = errMsg.contains("lock", ignoreCase = true) || errMsg.contains("already locked", ignoreCase = true)
if (hasLock) {
_uiState.update { it.copy(resticStatus = ResticStatus(
message = "仓库被锁定,请先解锁",
initButtonVisible = false, statsButtonVisible = false, pruneButtonVisible = false,
unlockButtonVisible = true
))}
} else {
// snapshots 失败时自动尝试 init处理已初始化的旧仓库
val initResult = ResticWrapper.init(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
)
if (initResult.isSuccess) {
val snaps = ResticWrapper.listSnapshots(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
).getOrDefault(emptyList())
_uiState.update { it.copy(resticStatus = ResticStatus(
message = "仓库就绪,${snaps.size} 个快照",
snapshotCount = snaps.size,
initButtonVisible = false, statsButtonVisible = true, pruneButtonVisible = true,
unlockButtonVisible = true
))}
} else {
_uiState.update { it.copy(resticStatus = ResticStatus(
message = "仓库未初始化或认证失败",
initButtonVisible = true, statsButtonVisible = false, pruneButtonVisible = false,
unlockButtonVisible = false
))}
}
}
}
}
}
fun unlockResticRepo(form: ResticForm) {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "正在解锁仓库…", unlockButtonEnabled = false
))}
viewModelScope.launch {
ResticWrapper.backendDomain = form.backendDomain
val result = ResticWrapper.unlock(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = 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
@@ -313,6 +419,15 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch {
try {
_operationEvents.emit(OperationEvent.PruneStarted)
// Remove stale locks before forget/prune
ResticWrapper.backendDomain = form.backendDomain
ResticWrapper.unlock(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
)
val forgetResult = ResticWrapper.forget(form.repo, form.password,
keepDaily = 7, keepWeekly = 4, keepMonthly = 3,
backend = form.backend, backendUrl = form.backendUrl,

View File

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

View File

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

View File

@@ -0,0 +1,444 @@
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.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 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") }
// 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("本地备份")
}
Button(
onClick = {
val config = resticConfig ?: run {
statusText = "未配置 Restic请先在设置中配置"
return@Button
}
scope.launch {
isRunning = true
statusText = "正在读取快照…"
try {
val result = withContext(Dispatchers.IO) {
ResticWrapper.listSnapshots(
config.resticRepo, config.resticPassword,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
)
}
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) {
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,
)
}
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("取消") }
}
)
}
}
// ── Sub-composables ──
// ── Helper functions ──
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 = if (appListFile.exists()) {
appListFile.readLines()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
} else {
dir.listFiles()
?.filter { it.isDirectory }
?.map { it.name }
?: emptyList()
}
// Read cached labels from app_details.json (includes uninstalled apps)
val cachedLabels = readLocalAppDetails(dir)
val preLabeled = pkgs.map { pkg ->
AppInfo(packageName = PackageName(pkg), label = cachedLabels[pkg] ?: "")
}
// Resolve labels for currently installed apps, keep cached labels for uninstalled
val resolved = AppScanner.resolveLabels(context, preLabeled)
// For apps that resolveLabels fell back to package name, restore cached label
val infos = resolved.map { app ->
val cachedLabel = cachedLabels[app.packageName.value]
if (cachedLabel != null && app.label == app.packageName.value) app.copy(label = cachedLabel)
else app
}
onResult(pkgs, infos, "${pkgs.size} 个备份应用")
}
}
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
}
val dumpResult = ResticWrapper.dump(
config.resticRepo, config.resticPassword,
snapshot.id, "$backupPath/appList.txt",
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
)
val content = dumpResult.getOrNull()
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")
if (!metaFile.exists()) return@withContext emptyMap()
try {
val json = metaFile.readText()
ResticWrapper.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> {
val dumpResult = ResticWrapper.dump(
config.resticRepo, config.resticPassword,
snapshotId, "$backupPath/app_details.json",
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
)
val json = dumpResult.getOrNull() ?: return emptyMap()
return try {
ResticWrapper.parseAppDetailsJson(json).mapValues { it.value.label }
} catch (_: Exception) { emptyMap() }
}

View File

@@ -0,0 +1,69 @@
package com.example.androidbackupgui.ui.theme
import androidx.compose.ui.graphics.Color
// Material 3 light scheme colors
val md_theme_light_primary = Color(0xFF1A6B52)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFA4F2D3)
val md_theme_light_onPrimaryContainer = Color(0xFF002117)
val md_theme_light_secondary = Color(0xFF4C6359)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFCEE9DB)
val md_theme_light_onSecondaryContainer = Color(0xFF092017)
val md_theme_light_tertiary = Color(0xFF3F6373)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFC3E8FB)
val md_theme_light_onTertiaryContainer = Color(0xFF001F29)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFBFDF9)
val md_theme_light_onBackground = Color(0xFF191C1A)
val md_theme_light_surface = Color(0xFFFBFDF9)
val md_theme_light_onSurface = Color(0xFF191C1A)
val md_theme_light_surfaceVariant = Color(0xFFDBE5DD)
val md_theme_light_onSurfaceVariant = Color(0xFF404943)
val md_theme_light_outline = Color(0xFF707973)
val md_theme_light_outlineVariant = Color(0xFFBFC9C2)
val md_theme_light_inverseSurface = Color(0xFF2E312E)
val md_theme_light_inverseOnSurface = Color(0xFFF0F1ED)
val md_theme_light_inversePrimary = Color(0xFF88D6B8)
val md_theme_light_surfaceTint = Color(0xFF1A6B52)
// Material 3 dark scheme colors
val md_theme_dark_primary = Color(0xFF88D6B8)
val md_theme_dark_onPrimary = Color(0xFF003828)
val md_theme_dark_primaryContainer = Color(0xFF00513C)
val md_theme_dark_onPrimaryContainer = Color(0xFFA4F2D3)
val md_theme_dark_secondary = Color(0xFFB3CCC0)
val md_theme_dark_onSecondary = Color(0xFF1F352B)
val md_theme_dark_secondaryContainer = Color(0xFF354B41)
val md_theme_dark_onSecondaryContainer = Color(0xFFCEE9DB)
val md_theme_dark_tertiary = Color(0xFFA7CCDF)
val md_theme_dark_onTertiary = Color(0xFF083544)
val md_theme_dark_tertiaryContainer = Color(0xFF254B5B)
val md_theme_dark_onTertiaryContainer = Color(0xFFC3E8FB)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF191C1A)
val md_theme_dark_onBackground = Color(0xFFE1E3DF)
val md_theme_dark_surface = Color(0xFF191C1A)
val md_theme_dark_onSurface = Color(0xFFE1E3DF)
val md_theme_dark_surfaceVariant = Color(0xFF404943)
val md_theme_dark_onSurfaceVariant = Color(0xFFBFC9C2)
val md_theme_dark_outline = Color(0xFF89938C)
val md_theme_dark_outlineVariant = Color(0xFF404943)
val md_theme_dark_inverseSurface = Color(0xFFE1E3DF)
val md_theme_dark_inverseOnSurface = Color(0xFF191C1A)
val md_theme_dark_inversePrimary = Color(0xFF1A6B52)
val md_theme_dark_surfaceTint = Color(0xFF88D6B8)
// Status colors
val StatusSuccess = Color(0xFF2E7D32)
val StatusWarning = Color(0xFFF57F17)
val StatusError = Color(0xFFD32F2F)
val StatusInfo = Color(0xFF1976D2)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="checkbox" type="id" />
<item name="appName" type="id" />
<item name="excludeToggle" 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,62 @@
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("plain password survives round trip") {
val c = BackupConfig(resticPassword = "simple123")
roundTrip(c).resticPassword shouldBe "simple123"
}
test("password with double-quote survives round trip") {
val c = BackupConfig(resticPassword = "pa\"ss\"word")
roundTrip(c).resticPassword shouldBe "pa\"ss\"word"
}
test("password with backslash survives round trip") {
val c = BackupConfig(resticPassword = "p\\a\\ss")
roundTrip(c).resticPassword shouldBe "p\\a\\ss"
}
test("password with leading and trailing spaces survives round trip") {
val c = BackupConfig(resticPassword = " sp ace ")
roundTrip(c).resticPassword shouldBe " sp ace "
}
test("password with special shell characters survives round trip") {
val c = BackupConfig(resticPassword = "p@\$s#w!ord&")
roundTrip(c).resticPassword shouldBe "p@\$s#w!ord&"
}
test("restic_backend_pass with quote and backslash survives round trip") {
val c = BackupConfig(resticBackendPass = "a\\\"b")
roundTrip(c).resticBackendPass shouldBe "a\\\"b"
}
test("output path with spaces survives round trip") {
val c = BackupConfig(outputPath = "/sdcard/my backups/")
roundTrip(c).outputPath shouldBe "/sdcard/my backups/"
}
test("non-restic fields are unaffected") {
val c = BackupConfig(backupMode = 1, backupWifi = 0, compressionMethod = "zstd")
val out = roundTrip(c)
out.backupMode shouldBe 1
out.backupWifi shouldBe 0
out.compressionMethod shouldBe "zstd"
}
})

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

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

View File

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

View File

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

View File

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

77
gradlew.bat vendored Normal file
View File

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

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

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

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

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