52 Commits
v1.1 ... v1.12

Author SHA1 Message Date
sakuradairong
1f3e1ceea8 release: v1.12
fix: HEAD /backup/config 响应 Content-Length 为 0,restic 以为 config 空文件
fix: 添加 RemoteTransport.fileSize() 方法,HEAD 返回实际文件大小
feat: SmbTransport/WebdavTransport 实现 fileSize
2026-06-06 01:07:12 +08:00
sakuradairong
3813f49a12 release: v1.11
fix: restic REST URI 含 repo 前缀 (/backup/config), 桥接器未剥离导致
     type/name 解析错误, remoteBase + URI 双重拼接产生路径嵌套

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

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

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

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

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

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

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

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

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

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

This replaces the previous 4-tier fallback hacks with a proper
shell-level fix at the libsu configuration layer.
2026-06-01 22:42:51 +08:00
sakuradairong
d0bfef41c8 fix: replace su -Z with magiskpolicy SELinux relax in backupUserData fallback 2026-06-01 22:33:16 +08:00
sakuradairong
0bde3b0a75 fix: try su -Z u:r:magisk:s0 for SELinux context switch
Magisk 30+ adds -Z flag to switch SELinux context. On this device,
the app's su runs as u:r:untrusted_app:s0 which cannot access other
apps' /data/data/. Switching to u:r:magisk:s0 lifts this restriction.
2026-06-01 21:57:11 +08:00
sakuradairong
d2ea9f532f fix: add su -mm fallback for Magisk isolated mount namespace
On some Magisk/Zygisk configurations, the app's su session stays
in an isolated mount namespace AND SELinux context that blocks even
/proc/1/root access. Add su -mm (Magisk mount namespace master) as
a fourth fallback attempt — it forkes a new su session in the global
mount namespace where all /data/data/ directories are accessible.
2026-06-01 21:45:39 +08:00
sakuradairong
ac0fd8b063 fix: access app data via /proc/1/root for isolated mount namespace
On some Magisk/Zygisk configurations, su from an app process stays
in the app's mount namespace where /data/data/<other-pkg> is not
visible (exit 1, 'No such file or directory').

Fix: three-tier fallback in backupUserData:
1. test -d on direct paths (standard)
2. tar directly on direct paths (covers test -d edge cases)
3. cd /proc/1/root && tar (crosses mount namespace boundary to init's
   global namespace, where all /data/data/ directories are visible)
2026-06-01 21:35:45 +08:00
sakuradairong
fde6d05b83 fix: bypass test -d quoting issue, use raw pkg paths with tar fallback
libsu's shell wrapper mishandles single-quoted path segments from
shellEscape(), causing test -d /data/data/'tv.danmaku.bili' to return
exit 1 even though the directory exists. Fix:

- Use raw (unescaped) package name for path checking commands
- Fallback: if test -d fails, try tar directly (which works even when
  test -d doesn't) to detect and back up existing data directories
- Restructure to avoid double tar execution in fallback path
2026-06-01 21:29:16 +08:00
sakuradairong
2ff096ee8a debug: add ls-d fallback when test -d fails in backupUserData 2026-06-01 21:21:35 +08:00
sakuradairong
eae7f4b369 debug: log backupUserData test -d exit codes to diagnose missing data archive 2026-06-01 21:18:19 +08:00
sakuradairong
420d960ce1 fix: restoreData logging catches empty filter result
The ?: operator only triggers on null, not empty list.
Replace with explicit null check + filter + empty check,
logging actual filenames when no _data.tar is found.
2026-06-01 21:02:10 +08:00
sakuradairong
88e81e956c debug: add logging to restoreData/restoreObb for silent failure diagnosis
restoreData and restoreObb currently log nothing on failure and return
Unit, making it impossible to diagnose why data archives aren't being
extracted. Add Log.d/w/e calls for every step: file discovery, archive
safety check, extraction command, and result.
2026-06-01 20:46:55 +08:00
sakuradairong
c12f7a9c81 chore: cleanup restic_tmp directory after sync 2026-06-01 20:27:59 +08:00
sakuradairong
a43638698c fix: set TMPDIR for restic pack files on Android
Android has no /tmp directory, causing restic backup to fail with:
  Fatal: unable to save snapshot: open /tmp/restic-temp-pack-...: no such file or directory

- Add TMPDIR env var pointing to a writable app cache subdirectory
- Ensure the tmp directory is created before restic runs
2026-06-01 20:27:30 +08:00
sakuradairong
12fe29f841 fix: preserve restic error in final status, revert binary copy
- Restic error message was overwritten by '备份完成!' before user
  could see it — now included in final status as '── Restic 错误 ──'
- Revert copy-to-filesDir: SELinux blocks exec from app private dir
- Use native library directory directly (Android extracts with exec perms)
2026-06-01 20:23:37 +08:00
sakuradairong
b7addcca6b fix: revert ResticBinary to native lib dir, use direct exec path
The copy-to-filesDir approach fails on Android 10+ because SELinux
blocks execution from app private data directories even with
setExecutable(true). Revert to using the native library directory
directly — Android PackageManager extracts native libs with proper
execution permissions there.

Also: the copy change also reset ResticBinary.cacheInit=false, meaning
the cached "Permission denied" path would be re-picked up. Full revert
ensures the next prepare() call uses the correct executable path.
2026-06-01 20:20:51 +08:00
sakuradairong
98d4029fd4 fix: reload config onResume so Restic settings take effect without restart
BackupFragment and RestoreFragment read config only in onViewCreated(),
so config changes saved in ConfigFragment (e.g. enabling Restic) were
picked up only after app restart (when fragments are recreated). Add
onResume() to both fragments to re-read config from file.
2026-06-01 20:14:10 +08:00
sakuradairong
07366f744f fix: copy restic binary to writable dir before execution
Native library directory may be mounted noexec on Android 10+,
preventing ProcessBuilder from executing librestic.so directly.
Copy to app's filesDir/restic_bin/librestic and set executable
permission before use.
2026-06-01 20:12:33 +08:00
sakuradairong
88e18f4c57 chore: gitignore restic test repo containing encryption keys 2026-06-01 17:44:53 +08:00
sakuradairong
d8992c3931 chore: gitignore memory files from agent harness
memory:/ paths resolve to literal files in the project directory.
Add gitignore pattern to prevent accidental commits.
2026-06-01 17:39:00 +08:00
sakuradairong
c7e9d8b5f1 chore: remove memory files from git tracking (internal harness paths) 2026-06-01 17:38:07 +08:00
sakuradairong
3e1f8a5937 fix: conditionally sign release APK when keystore exists
release.keystore is gitignored (regenerate locally), so CI checks out
without it. Make signingConfig and signing reference conditional on
file existence, allowing CI to produce an unsigned APK while local
builds still get a signed release.
2026-06-01 17:38:02 +08:00
sakuradairong
80b84f6cff refactor: migrate to libsu root shell with unified exec interface
Replace custom persistent su process management (Mutex-guarded
process, InputStream/OutputStreamReader) with libsu's Shell.cmd.

RootShell.kt changes:
- Remove: process lifecycle, mutex, stdin/stdout/stderr management,
  execBinary/execStreaming, sentinel health-check pattern
- Add: libsu Shell.getShell()/Shell.cmd() with structured timeout,
  single public exec() method returning ShellResult
- Keep: shellEscape() extension, ensureSession(), ShellResult data class

Caller changes:
- WifiManager.kt: add return-value checks for mkdir, chown, chmod;
  mark best-effort wifi reload as intentionally unchecked

Supporting:
- app/build.gradle: add libsu 6.0.0 dependency
- AGENTS.md / CLAUDE.md: update GitNexus index stats
2026-06-01 17:27:26 +08:00
sakuradairong
0b017e853b fix: restore review fixes — snapshot dialog, staging finally, permission try-catch 2026-06-01 16:53:43 +08:00
sakuradairong
2351cce99e fix: incremental backup progress — add message_type check, skip count in sync progress 2026-06-01 16:45:16 +08:00
58 changed files with 5201 additions and 1708 deletions

7
.gitignore vendored
View File

@@ -16,3 +16,10 @@ Thumbs.db
# Keystore (regenerate if needed)
debug.keystore
release.keystore
# Memory files from agent harness
memory:*
# Restic test repository (contains encryption keys)
test/
kmboxnet

View File

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

@@ -9,6 +9,7 @@ Android 应用备份与恢复工具,集成 [restic](https://restic.net/) 实
- **并行备份/恢复** — 备份并发数 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 月)
@@ -98,17 +99,26 @@ ConfigViewModel ResticWrapper
- **文件大小限制** — WebDAV 上传 50MB 上限(防止 ByteArray OOM
- **存档完整性校验** — 备份后 zstd/gzip 验证数据归档,校验失败回告
## 编译
## 构建
### 版本历史
|-|版本|更新内容|
|-|---:|--------|
| | v1.3 | 累积快照、AppResult 类型化错误、RootShell Mutex、kotlinx-serialization 迁移 |
| | v1.4 | APK 体积优化ProGuard/R8 + shrinkResources + 依赖裁剪Release APK 从 25 MB 降至 11.8 MB-52.8% |
### 编译命令
```bash
# Debug APK
# Debug APK(不压缩,适合开发调试)
./gradlew assembleDebug
# Release (需配置签名)
# Release APKProGuard/R8 混淆 + 资源裁剪 + 签名
./gradlew assembleRelease
```
`librestic.so`放在 `app/src/main/jniLibs/arm64-v8a/` 目录下,在 `build.gradle` 中禁用 `extractNativeLibs` 前的 `useLegacyPackaging`
> Release 构建需配置 `release.keystore` 签名文件;`librestic.so` 放在 `app/src/main/jniLibs/arm64-v8a/`
## 使用说明

View File

@@ -1,6 +1,23 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
apply plugin: 'org.jetbrains.kotlinx.kover'
kover {
reports {
filters {
excludes {
classes(
// Generated/auto classes
"*.databinding.*",
"*.BuildConfig",
"*.R",
"*.R\$*"
)
}
}
}
}
android {
namespace "com.example.androidbackupgui"
@@ -9,8 +26,8 @@ android {
applicationId "com.example.androidbackupgui"
minSdk 24
targetSdk 34
versionCode 2
versionName "1.1"
versionCode 13
versionName "1.12"
}
buildFeatures {
viewBinding true
@@ -20,17 +37,25 @@ android {
}
signingConfigs {
release {
storeFile file("release.keystore")
storeFile rootProject.file("app/release.keystore")
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "android"
keyAlias "release"
keyPassword System.getenv("KEY_PASSWORD") ?: "android"
v1SigningEnabled true
v2SigningEnabled true
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
if (rootProject.file("app/release.keystore").exists()) {
signingConfig signingConfigs.release
}
}
}
testOptions {
unitTests.all {
useJUnitPlatform()
}
}
compileOptions {
@@ -44,6 +69,13 @@ android {
jniLibs {
useLegacyPackaging true
}
resources {
excludes += [
'org/bouncycastle/pqc/crypto/picnic/lowmcL5.bin.properties',
'org/bouncycastle/pqc/crypto/picnic/lowmcL3.bin.properties',
'org/bouncycastle/pqc/crypto/picnic/lowmcL1.bin.properties',
]
}
}
}
@@ -61,7 +93,23 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
// 方案A: jcifs-ng (SMB) + sardine-android (WebDAV) 替代 rclone serve
implementation "eu.agno3.jcifs:jcifs-ng:2.1.10"
implementation "com.github.thegrizzlylabs:sardine-android:v0.9"
implementation("eu.agno3.jcifs:jcifs-ng:2.1.10") {
exclude group: 'org.bouncycastle'
}
implementation("com.github.thegrizzlylabs:sardine-android:v0.9") {
exclude group: 'xpp3'
exclude group: 'stax'
}
implementation "org.slf4j:slf4j-android:1.7.36"
// root shell via libsu (Magisk/KernelSU/APatch)
implementation 'com.github.topjohnwu:libsu:6.0.0'
// Full BouncyCastle provider (includes MD4 required by jcifs-ng SMB)
implementation 'org.bouncycastle:bcprov-jdk15to18:1.77'
implementation 'org.nanohttpd:nanohttpd:2.3.1'
testImplementation "io.kotest:kotest-runner-junit5:5.9.1"
testImplementation "io.kotest:kotest-assertions-core:5.9.1"
testImplementation "io.kotest:kotest-property:5.9.1"
testImplementation "io.mockk:mockk:1.13.12"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
}

View File

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

View File

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

View File

@@ -12,12 +12,14 @@ import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.example.androidbackupgui.databinding.ActivityMainBinding
import com.example.androidbackupgui.root.RootShell
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.google.android.material.color.DynamicColors
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : AppCompatActivity() {
@@ -34,15 +36,47 @@ class MainActivity : AppCompatActivity() {
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// Configure libsu with global mount namespace support
RootShell.configure()
// Request root access on startup
lifecycleScope.launch(Dispatchers.IO) {
RootShell.ensureSession()
lifecycleScope.launch {
withContext(Dispatchers.IO) {
RootShell.ensureSession()
}
// Initialize file-based logging
LogUtil.init(filesDir)
}
// Edge-to-edge: pad toolbar below status bar
ViewCompat.setOnApplyWindowInsetsListener(binding.topAppBar) { view, insets ->
// 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())
view.setPadding(view.paddingLeft, statusBars.top, view.paddingRight, view.paddingBottom)
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
}
@@ -54,6 +88,7 @@ class MainActivity : AppCompatActivity() {
binding.viewPager.adapter = TabAdapter(this, fragments)
binding.viewPager.isUserInputEnabled = true
binding.viewPager.offscreenPageLimit = 2
binding.bottomNav.setOnItemSelectedListener { item ->
when (item.itemId) {

View File

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

View File

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

View File

@@ -6,36 +6,38 @@ import kotlinx.serialization.Serializable
/**
* Mirrors backup_settings.conf from backup_script.
* All keys correspond 1:1 with the original shell config.
*
* This is an immutable data class. Use [copy] to create modified instances.
*/
@Serializable
data class BackupConfig(
// Operation mode
var lo: Int = 0, // 0=volume key, 1=volume force, 2=keyboard
var backgroundExecution: Int = 0, // 0=foreground, 1=background
var setDisplayPowerMode: Int = 0, // 1=keep screen on during backup
var shellLang: String = "", // ""=auto, "1"=zh-CN, "0"=zh-TW
val lo: Int = 0, // 0=volume key, 1=volume force, 2=keyboard
val backgroundExecution: Int = 0, // 0=foreground, 1=background
val setDisplayPowerMode: Int = 0, // 1=keep screen on during backup
val shellLang: String = "", // ""=auto, "1"=zh-CN, "0"=zh-TW
// Paths
var outputPath: String = "", // Custom output dir
var listLocation: String = "", // Custom appList.txt location
val outputPath: String = "", // Custom output dir
val listLocation: String = "", // Custom appList.txt location
// Update
var update: Int = 1, // 1=auto update
var cdn: Int = 1, // CDN node
val update: Int = 1, // 1=auto update
val cdn: Int = 1, // CDN node
// Filters
var mountPoint: String = "rannki|0000-1",
var user: String = "",
val mountPoint: String = "rannki|0000-1",
val user: String = "",
// Backup mode
var backupMode: Int = 1, // 1=data+apk, 0=apk only
var backupUserData: Int = 1,
var backupObbData: Int = 1,
var backupMedia: Int = 0,
var backgroundAppsIgnore: Int = 0,
val backupMode: Int = 1, // 1=data+apk, 0=apk only
val backupUserData: Int = 1,
val backupObbData: Int = 1,
val backupMedia: Int = 0,
val backgroundAppsIgnore: Int = 0,
// Custom paths
var customPath: List<String> = listOf(
val customPath: List<String> = listOf(
"/storage/emulated/0/Pictures/",
"/storage/emulated/0/Download/",
"/storage/emulated/0/Music",
@@ -44,38 +46,37 @@ data class BackupConfig(
),
// Blacklist
var blacklistMode: Int = 0, // 1=full ignore, 0=apk only
var blacklist: List<String> = emptyList(),
val blacklistMode: Int = 0, // 1=full ignore, 0=apk only
val blacklist: List<String> = emptyList(),
// Whitelists
var whitelist: List<String> = emptyList(),
var system: List<String> = emptyList(),
val whitelist: List<String> = emptyList(),
val system: List<String> = emptyList(),
// Compression
var compressionMethod: String = "zstd", // zstd or tar
val compressionMethod: String = "zstd", // zstd or tar
// Terminal colors
var rgbA: Int = 226,
var rgbB: Int = 123,
var rgbC: Int = 177,
val rgbA: Int = 226,
val rgbB: Int = 123,
val rgbC: Int = 177,
var backupWifi: Int = 1,
val backupWifi: Int = 1,
// Restic deduplicated backup with rclone backend
var resticEnabled: Int = 0,
var resticRepo: String = "",
var resticPassword: String = "",
var resticBackend: String = "local", // local / webdav / smb
var resticBackendUrl: String = "",
var resticBackendUser: String = "",
var resticBackendPass: String = "",
var resticBackendShare: String = "", // SMB share name
var resticBackendDomain: String = "" // SMB domain (optional, for NTLM)
val resticEnabled: Int = 0,
val resticRepo: String = "",
val resticPassword: String = "",
val resticBackend: String = "local", // local / webdav / smb
val resticBackendUrl: String = "",
val resticBackendUser: String = "",
val resticBackendPass: String = "",
val resticBackendShare: String = "", // SMB share name
val resticBackendDomain: String = "" // SMB domain (optional, for NTLM)
) {
companion object {
fun fromFile(file: File): BackupConfig {
val config = BackupConfig()
if (!file.exists()) return config
if (!file.exists()) return BackupConfig()
val props = mutableMapOf<String, String>()
file.forEachLine { line ->
@@ -97,41 +98,42 @@ data class BackupConfig(
.map { it.replace("%20", " ") }
}
config.lo = int("Lo")
config.backgroundExecution = int("background_execution")
config.setDisplayPowerMode = int("setDisplayPowerMode")
config.shellLang = str("Shell_LANG")
config.outputPath = str("Output_path")
config.listLocation = str("list_location")
config.update = int("update", default = 1)
config.cdn = int("cdn", default = 1)
config.mountPoint = str("mount_point")
config.user = str("user")
config.backupMode = int("Backup_Mode", default = 1)
config.backupUserData = int("Backup_user_data", default = 1)
config.backupObbData = int("Backup_obb_data", default = 1)
config.backupMedia = int("backup_media")
config.backgroundAppsIgnore = int("Background_apps_ignore")
config.customPath = lines("Custom_path")
config.blacklistMode = int("blacklist_mode")
config.blacklist = lines("blacklist")
config.whitelist = lines("whitelist")
config.system = lines("system")
config.compressionMethod = str("Compression_method").ifEmpty { "zstd" }
config.rgbA = int("rgb_a").let { if (it == 0) 226 else it }
config.rgbB = int("rgb_b").let { if (it == 0) 123 else it }
config.rgbC = int("rgb_c").let { if (it == 0) 177 else it }
config.backupWifi = int("backup_wifi", default = 1)
config.resticEnabled = int("restic_enabled")
config.resticRepo = str("restic_repo")
config.resticPassword = str("restic_password")
config.resticBackend = str("restic_backend").ifEmpty { "local" }
config.resticBackendUrl = str("restic_backend_url")
config.resticBackendUser = str("restic_backend_user")
config.resticBackendPass = str("restic_backend_pass")
config.resticBackendShare = str("restic_backend_share")
config.resticBackendDomain = str("restic_backend_domain")
return config
return BackupConfig(
lo = int("Lo"),
backgroundExecution = int("background_execution"),
setDisplayPowerMode = int("setDisplayPowerMode"),
shellLang = str("Shell_LANG"),
outputPath = str("Output_path"),
listLocation = str("list_location"),
update = int("update", default = 1),
cdn = int("cdn", default = 1),
mountPoint = str("mount_point"),
user = str("user"),
backupMode = int("Backup_Mode", default = 1),
backupUserData = int("Backup_user_data", default = 1),
backupObbData = int("Backup_obb_data", default = 1),
backupMedia = int("backup_media"),
backgroundAppsIgnore = int("Background_apps_ignore"),
customPath = lines("Custom_path"),
blacklistMode = int("blacklist_mode"),
blacklist = lines("blacklist"),
whitelist = lines("whitelist"),
system = lines("system"),
compressionMethod = str("Compression_method").ifEmpty { "zstd" },
rgbA = int("rgb_a").let { if (it == 0) 226 else it },
rgbB = int("rgb_b").let { if (it == 0) 123 else it },
rgbC = int("rgb_c").let { if (it == 0) 177 else it },
backupWifi = int("backup_wifi", default = 1),
resticEnabled = int("restic_enabled"),
resticRepo = str("restic_repo"),
resticPassword = str("restic_password"),
resticBackend = str("restic_backend").ifEmpty { "local" },
resticBackendUrl = str("restic_backend_url"),
resticBackendUser = str("restic_backend_user"),
resticBackendPass = str("restic_backend_pass"),
resticBackendShare = str("restic_backend_share"),
resticBackendDomain = str("restic_backend_domain"),
)
}
fun toFile(config: BackupConfig, file: File) {

View File

@@ -1,17 +1,19 @@
package com.example.androidbackupgui.backup
import com.example.androidbackupgui.backup.ResticWrapper.SnapshotAppInfo
import com.example.androidbackupgui.root.RootShell
import android.util.Log
import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
import kotlin.coroutines.coroutineContext
import kotlinx.serialization.Serializable
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.util.concurrent.atomic.AtomicInteger
@@ -48,13 +50,21 @@ object BackupOperation {
* @param config backup configuration
* @param outputDir root output directory
* @param userId Android user ID (0, 999, etc.)
* @param onProgress callback for UI updates
* @param includePkgs if non-empty, only backup apps whose package name is in this set;
* metadata (app_details.json, appList.txt) is still generated for all [apps].
* @param legacyApps metadata from a previous snapshot used to populate app_details.json
* for apps not in [apps] (keeps them in the cumulative snapshot record
* without requiring re-scans of possibly-uninstalled apps).
*/
suspend fun backupApps(
context: android.content.Context,
apps: List<AppInfo>,
config: BackupConfig,
outputDir: File,
userId: String = "0",
noDataBackup: Set<String> = emptySet(),
includePkgs: Set<String> = emptySet(),
legacyApps: Map<String, SnapshotAppInfo>? = null,
onProgress: suspend (BackupProgress) -> Unit = {}
): BackupResult = withContext(Dispatchers.IO) {
val emit: suspend (BackupProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
@@ -63,32 +73,36 @@ object BackupOperation {
// Create backup structure
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
backupRoot.mkdirs()
LogUtil.i(TAG, "backupApps: starting backup of ${apps.size} apps to ${backupRoot.absolutePath}")
// Write app list
// Write app list — includes ALL packages in [apps] (selected + legacy from snapshot)
val appListFile = File(backupRoot, "appList.txt")
appListFile.writeText(apps.joinToString("\n") { it.packageName })
appListFile.writeText(apps.joinToString("\n") { it.packageName.value })
// Write metadata JSON
// Write metadata JSON — fresh metadata for selected apps, legacy for historical apps
val metaFile = File(backupRoot, "app_details.json")
metaFile.writeText(buildAppDetailsJson(apps))
metaFile.writeText(buildAppDetailsJson(apps, legacyApps))
val backupTargets = if (includePkgs.isEmpty()) apps else apps.filter { it.packageName.value in includePkgs }
val totalCount = backupTargets.size
LogUtil.i(TAG, "backupApps: includePkgs=${includePkgs.size} targets=$totalCount")
val semaphore = Semaphore(3)
val successAtomic = AtomicInteger(0)
val failAtomic = AtomicInteger(0)
val skippedAtomic = AtomicInteger(0)
coroutineScope {
apps.forEachIndexed { index, app ->
launch {
if (!coroutineContext.isActive) return@launch
backupTargets.mapIndexed { index, app ->
async {
semaphore.withPermit {
val appDir = File(backupRoot, app.packageName)
ensureActive()
val appDir = File(backupRoot, app.packageName.value)
appDir.mkdirs()
emit(BackupProgress(index + 1, apps.size, app.packageName, "apk", "正在备份 APK…"))
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "apk", "正在备份 APK…"))
// 1. Backup APK
val paths = AppScanner.getApkPaths(app.packageName)
val paths = AppScanner.getApkPaths(app.packageName.value)
val apkOk = if (paths.isNotEmpty()) {
paths.withIndex().all { (i, apkPath) ->
val destName = if (paths.size > 1) "${app.packageName}_split_$i.apk" else "${app.packageName}.apk"
@@ -98,54 +112,76 @@ object BackupOperation {
if (!apkOk) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "APK 备份失败"))
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "APK 备份失败"))
return@withPermit
}
// 1.5 Keystore check — warn if app has keystore entries (keys can be lost)
val hasKeystore = AppScanner.hasKeystore(app.packageName.value)
if (hasKeystore) {
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失"))
}
// 2. Backup user data (if configured)
if (config.backupMode == 1 && config.backupUserData == 1) {
emit(BackupProgress(index + 1, apps.size, app.packageName, "data", "正在备份数据…"))
if (!backupUserData(app.packageName, appDir, userId, config.compressionMethod)) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "数据备份失败"))
return@withPermit
if (app.packageName.value in noDataBackup) {
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "跳过数据备份(已排除)"))
} else {
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "正在备份数据…"))
if (!backupUserData(context, app.packageName.value, appDir, userId, config.compressionMethod)) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "数据备份失败"))
return@withPermit
}
}
}
// 3. Backup OBB (if configured and exists)
if (config.backupMode == 1 && config.backupObbData == 1) {
val hasObb = AppScanner.hasObbData(app.packageName)
val hasObb = AppScanner.hasObbData(app.packageName.value)
if (hasObb) {
emit(BackupProgress(index + 1, apps.size, app.packageName, "obb", "正在备份 OBB…"))
if (!backupObb(app.packageName, appDir, config.compressionMethod)) {
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "obb", "正在备份 OBB…"))
if (!backupObb(app.packageName.value, appDir, config.compressionMethod)) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "OBB 备份失败"))
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "OBB 备份失败"))
return@withPermit
}
}
}
// 4. Backup SSAID
emit(BackupProgress(index + 1, apps.size, app.packageName, "ssaid", "正在备份 SSAID…"))
backupSsaid(app.packageName, appDir, userId)
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "ssaid", "正在备份 SSAID…"))
backupSsaid(app.packageName.value, appDir, userId)
// 4.5 Backup app icon
val iconPath = AppScanner.extractIcon(app.packageName.value, appDir, app.userId.value)
if (iconPath != null) {
Log.d(TAG, "backupApps: saved icon for ${app.packageName} -> $iconPath")
}
// 5. Backup runtime permissions
backupPermissions(app.packageName, appDir)
backupPermissions(app.packageName.value, appDir)
successAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "完成"))
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "完成"))
}
}
}
}.awaitAll()
}
val elapsed = System.currentTimeMillis() - startTime
RootShell.exec("chmod -R 0755 '${backupRoot.absolutePath}'")
val successCount = successAtomic.get()
val failCount = failAtomic.get()
val skippedCount = skippedAtomic.get()
LogUtil.i(TAG, "backupApps: completed — success=$successCount fail=$failCount skipped=$skippedCount elapsed=${elapsed}ms")
BackupResult(
successCount = successAtomic.get(),
failCount = failAtomic.get(),
skippedCount = skippedAtomic.get(),
successCount = successCount,
failCount = failCount,
skippedCount = skippedCount,
outputDir = backupRoot.absolutePath,
elapsedMs = elapsed
)
@@ -153,58 +189,125 @@ object BackupOperation {
private suspend fun backupUserData(
context: android.content.Context,
packageName: String,
appDir: File,
userId: String,
compression: String
): Boolean {
val pkgEsc = packageName.shellEscape()
val dataDir = "/data/data/$pkgEsc"
val userDeDir = "/data/user_de/${userId.shellEscape()}/$pkgEsc"
val outputFile = "${appDir.absolutePath.shellEscape()}/${pkgEsc}_data.tar"
// Build a list of dirs that exist
val dirs = mutableListOf<String>()
if (RootShell.exec("test -d $dataDir").isSuccess) dirs.add(dataDir)
if (RootShell.exec("test -d $userDeDir").isSuccess) dirs.add(userDeDir)
if (dirs.isEmpty()) return true // no data to backup is not an error
// Exclude cache, code_cache, lib
val excludeArgs = "--exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup'"
val result = when (compression) {
"zstd" -> {
val dirList = dirs.joinToString(" ")
RootShell.exec(
"tar $excludeArgs -cf - $dirList 2>/dev/null | zstd -T0 -o '$outputFile.zst'"
)
}
else -> {
val dirList = dirs.joinToString(" ")
RootShell.exec(
"tar $excludeArgs -czf '$outputFile.gz' $dirList 2>/dev/null"
)
// Resolve bundled binary paths (fall back to system PATH if not bundled)
val bundledTar = BinaryResolver.tarPath(context)
val tarCmd = bundledTar ?: "tar"
var isZstd = compression == "zstd"
val bundledZstd = if (isZstd) BinaryResolver.zstdPath(context) else null
val zstdCmd = bundledZstd ?: "zstd"
if (isZstd && bundledZstd == null) {
val zstdCheck = RootShell.exec("$zstdCmd --version 2>/dev/null")
if (!zstdCheck.isSuccess) {
Log.w(TAG, "backupUserData: zstd not available, falling back to gzip")
isZstd = false
}
}
if (!result.isSuccess) {
Log.e(TAG, "Failed to backup data for $packageName: exit=${result.exitCode} err=${result.error}")
val archiveExt = if (isZstd) ".zst" else ".gz"
val archiveRaw = File(appDir, "${packageName}_data.tar$archiveExt")
Log.d(TAG, "backupUserData: $packageName checking dirs (tar=$tarCmd zstd=$zstdCmd)")
val rawPkg = packageName
val dataPaths = listOf("/data/data/$rawPkg", "/data/user_de/$userId/$rawPkg")
val dataExcludes = listOf(".ota", "cache", "lib", "code_cache", "no_backup")
// 1. Try direct paths after nsenter namespace switch
var archiveCreated = false
var result: RootShell.ShellResult? = null
val dirs = dataPaths.filter { RootShell.exec("test -d '${it.shellEscape()}'").isSuccess }.toMutableList()
if (dirs.isNotEmpty()) {
Log.d(TAG, "backupUserData: $packageName test -d found dirs=$dirs")
result = runTar(dirs, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
Log.d(TAG, "backupUserData: $packageName step1 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
} else {
Log.d(TAG, "backupUserData: $packageName test -d all failed, trying tar directly")
result = runTar(dataPaths, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
Log.d(TAG, "backupUserData: $packageName step2 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
}
// 3. Fallback via /proc/1/root (global mount namespace)
if (!archiveCreated) {
Log.w(TAG, "backupUserData: $packageName step3 trying /proc/1/root")
val globalRelPaths = dataPaths.map { it.removePrefix("/") }
val globalCmd = if (isZstd) {
"cd /proc/1/root && set -o pipefail; $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
} else {
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null"
}
result = RootShell.exec(globalCmd)
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
Log.d(TAG, "backupUserData: $packageName step3 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
}
if (!archiveCreated) {
Log.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)")
return true
}
// Verify compression integrity
val verifyOk = if (isZstd) {
RootShell.exec("$zstdCmd -t '$outputFile.zst' 2>/dev/null").isSuccess
} else {
RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess
}
if (!verifyOk) {
Log.e(TAG, "backupUserData: $packageName integrity check FAILED")
return false
}
// Verify the compressed archive integrity
val verificationOk = when (compression) {
"zstd" -> RootShell.exec("zstd -t '$outputFile.zst' 2>/dev/null").isSuccess
else -> RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess
// Validate tar archive structure (Android-DataBackup Tar.test() pattern)
val tarValidateOk = if (isZstd) {
RootShell.exec("$zstdCmd -d -c '$outputFile.zst' 2>/dev/null | tar -tf - > /dev/null 2>&1").isSuccess
} else {
RootShell.exec("tar -tf '$outputFile.gz' > /dev/null 2>&1").isSuccess
}
if (!verificationOk) {
Log.e(TAG, "Data archive integrity check FAILED for $packageName")
if (!tarValidateOk) {
Log.e(TAG, "backupUserData: $packageName tar archive structure validation FAILED")
return false
}
return verificationOk
return true
}
/** Run tar for given paths, building the appropriate zstd/gzip command. */
private suspend fun runTar(
dirs: List<String>,
outputFile: String,
isZstd: Boolean,
tarCmd: String = "tar",
zstdCmd: String = "zstd",
excludes: List<String> = emptyList()
): RootShell.ShellResult {
val excludeArgs = if (excludes.isNotEmpty()) {
excludes.joinToString(" ") { "--exclude='${it.shellEscape()}'" }
} else ""
return if (isZstd) {
RootShell.exec("set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'")
} else {
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null")
}
}
private suspend fun backupObb(packageName: String, appDir: File, compression: String): Boolean {
val obbDir = "/storage/emulated/0/Android/obb/${packageName.shellEscape()}"
val escapedAppDir = appDir.absolutePath.shellEscape()
val escapedPkg = packageName.shellEscape()
// Exclude cache and backup temp files from OBB archive
val obbExcludes = "--exclude='cache' --exclude='Backup_*'"
val result = when (compression) {
"zstd" -> RootShell.exec("tar -cf - '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'")
else -> RootShell.exec("tar -czf '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
"zstd" -> RootShell.exec("set -o pipefail; tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'")
else -> RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
}
if (!result.isSuccess) {
Log.e(TAG, "Failed to backup OBB for $packageName: exit=${result.exitCode} err=${result.error}")
@@ -216,14 +319,30 @@ object BackupOperation {
if (!verificationOk) {
Log.e(TAG, "OBB archive integrity check FAILED for $packageName")
}
return verificationOk
// Validate OBB tar structure
val tarListCmd = if (compression == "zstd") "zstd -d -c '$archive' 2>/dev/null | tar -tf - > /dev/null 2>&1" else "tar -tf '$archive' > /dev/null 2>&1"
val tarOk = RootShell.exec(tarListCmd).isSuccess
if (!tarOk) {
Log.e(TAG, "OBB tar structure validation FAILED for $packageName")
}
return verificationOk && tarOk
}
private suspend fun backupSsaid(packageName: String, appDir: File, userId: String) {
val ssaidFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
val result = RootShell.exec("grep '${packageName.shellEscape()}' '$ssaidFile' 2>/dev/null")
if (result.output.isNotBlank()) {
File(appDir, "ssaid.txt").writeText(result.output)
// Parse XML value attribute for this package's SSAID entry
val result = RootShell.exec("cat '$ssaidFile' 2>/dev/null")
if (!result.isSuccess || result.output.isBlank()) return
val ssaidLine = result.output.lines().firstOrNull { line ->
line.contains("packageName=\"$packageName\"") || line.contains("packageName='$packageName'")
}
val value = ssaidLine
?.substringAfter("value=\"")
?.substringBefore("\"")
?.takeIf { it.isNotBlank() }
if (value != null) {
File(appDir, "ssaid.txt").writeText(value)
Log.d(TAG, "backupSsaid: backed up SSAID for $packageName = $value")
}
}
@@ -234,13 +353,35 @@ object BackupOperation {
}
}
private fun buildAppDetailsJson(apps: List<AppInfo>): String {
internal suspend fun buildAppDetailsJson(
apps: List<AppInfo>,
legacyApps: Map<String, SnapshotAppInfo>? = null
): String {
val root = JSONObject()
// Generate fresh metadata for apps in the current app list
for (app in apps) {
val entry = JSONObject()
entry.put("label", app.label)
entry.put("isSystem", app.isSystem)
root.put(app.packageName, entry)
// Record APK file sizes for change detection in incremental backup
val paths = AppScanner.getApkPaths(app.packageName.value)
val sizes = paths.map { path ->
val result = RootShell.exec("stat -c%s '${path.shellEscape()}'")
if (result.isSuccess) result.output.trim().toLongOrNull() ?: 0L else 0L
}
entry.put("apkSizes", JSONArray(sizes))
root.put(app.packageName.value, entry)
}
// Include legacy apps not in current app list with preserved metadata
val legacyMap = legacyApps ?: emptyMap()
for ((pkg, legacy) in legacyMap) {
if (!root.has(pkg)) {
val entry = JSONObject()
entry.put("label", legacy.label)
entry.put("isSystem", legacy.isSystem)
entry.put("apkSizes", JSONArray(legacy.apkSizes))
root.put(pkg, entry)
}
}
return root.toString(2)
}

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
package com.example.androidbackupgui.backup
import kotlinx.serialization.Serializable
/**
* 类型安全的包名包装。
*
* 使用 [value] 获取原始字符串,用于 Android API 调用和 shell 命令。
*/
@JvmInline
@Serializable
value class PackageName(val value: String) {
init {
require(value.isNotBlank()) { "PackageName must not be blank" }
}
override fun toString(): String = value
}
/**
* 类型安全的用户 ID 包装。
*
* 使用 [value] 获取原始整数值。默认值 0 表示主用户 (Owner)。
*/
@JvmInline
@Serializable
value class UserId(val value: Int) {
init {
require(value >= 0) { "UserId must be non-negative, got $value" }
}
override fun toString(): String = value.toString()
companion object {
val Owner = UserId(0)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,131 @@
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

@@ -0,0 +1,137 @@
package com.example.androidbackupgui.backup
import android.util.Log
import org.bouncycastle.crypto.digests.MD4Digest
import org.bouncycastle.crypto.engines.AESEngine
import org.bouncycastle.crypto.macs.CMac
import org.bouncycastle.crypto.params.KeyParameter
import java.security.MessageDigest
import java.security.MessageDigestSpi
import java.security.Provider
import java.security.Security
import java.security.spec.AlgorithmParameterSpec
import javax.crypto.MacSpi
/**
* Injects missing algorithms (MD4, AESCMAC) into Android's BC provider
* for jcifs-ng SMB support.
*
* jcifs-ng instantiates [BouncyCastleProvider] and requests algorithms
* ([MessageDigest]"MD4", [Mac]"AESCMAC") that Android's built-in BC
* has removed. The BouncyCastleProvider class is shadowed by the boot
* classloader, so we patch `jcifs.util.Crypto.provider` via reflection.
*/
object MissingAlgoProvider {
private const val TAG = "MissingAlgoProvider"
private val registered = java.util.concurrent.atomic.AtomicBoolean(false)
private val patchProvider: Provider by lazy {
val bc = Security.getProvider("BC")
DelegatingBcProvider(bc)
}
fun register() {
if (!registered.compareAndSet(false, true)) return
try {
// 1. Replace cached provider in jcifs-ng classes
for (cn in listOf(
"jcifs.util.Crypto",
"jcifs.smb.NtlmUtil",
"jcifs.smb.NtlmPasswordAuthenticator",
"jcifs.ntlmssp.Type3Message",
"jcifs.smb.NtlmContext"
)) setProviderField(cn)
// 2. Verify
try {
val cl = Class.forName("jcifs.util.Crypto")
val getProv = cl.getDeclaredMethod("getProvider")
getProv.isAccessible = true
val actual = getProv.invoke(null) as Provider
Log.i(TAG, "Crypto.getProvider() => ${actual::class.java.simpleName} " +
"(hasMD4=${actual.getService("MessageDigest", "MD4") != null}, " +
"hasAESCMAC=${actual.getService("Mac", "AESCMAC") != null})")
} catch (ve: Exception) {
Log.w(TAG, "Verification failed after injection", ve)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to inject algorithms", e)
}
}
private fun setProviderField(clsName: String) {
try {
val cls = Class.forName(clsName)
for (f in cls.declaredFields) {
if (java.lang.reflect.Modifier.isStatic(f.modifiers) &&
Provider::class.java.isAssignableFrom(f.type)) {
f.isAccessible = true
f.set(null, patchProvider)
Log.i(TAG, "Set $clsName.${f.name} = DelegatingBcProvider")
return
}
}
Log.i(TAG, "No static Provider field in $clsName")
} catch (_: ClassNotFoundException) {
Log.i(TAG, "Class not found: $clsName")
}
}
// ── MD4 MessageDigestSpi ────────────────────────────────────
class Md4Spi : MessageDigestSpi() {
private val d = MD4Digest()
override fun engineGetDigestLength() = d.digestSize
override fun engineUpdate(b: Byte) { d.update(b) }
override fun engineUpdate(b: ByteArray, o: Int, l: Int) { d.update(b, o, l) }
override fun engineDigest(): ByteArray {
val r = ByteArray(d.digestSize); d.doFinal(r, 0); return r
}
override fun engineReset() { d.reset() }
}
// ── AESCMAC MacSpi ─────────────────────────────────────────
class AesCmacSpi : MacSpi() {
private val mac = CMac(AESEngine.newInstance())
override fun engineInit(key: java.security.Key, params: AlgorithmParameterSpec?) {
val raw = key.encoded ?: throw java.security.InvalidKeyException("AESCMAC key has no encoded form")
mac.init(KeyParameter(raw))
}
override fun engineUpdate(inp: Byte) { mac.update(inp) }
override fun engineUpdate(inp: ByteArray, o: Int, l: Int) { mac.update(inp, o, l) }
override fun engineDoFinal(): ByteArray {
val r = ByteArray(mac.macSize); mac.doFinal(r, 0); return r
}
override fun engineGetMacLength() = mac.macSize
override fun engineReset() { mac.reset() }
}
// ── Delegating provider ─────────────────────────────────────
/** A "BC"-named provider that delegates to [bc] except for patched algorithms. */
private class DelegatingBcProvider(
private val bc: Provider?
) : Provider("BC", bc?.version ?: 1.0, "BC + patches") {
init {
putService(Service(this, "MessageDigest", "MD4",
Md4Spi::class.java.name, null, null))
putService(Service(this, "Mac", "AESCMAC",
AesCmacSpi::class.java.name, null, null))
}
override fun getService(type: String, algorithm: String): Service? {
if (type == "MessageDigest" && algorithm.equals("MD4", ignoreCase = true)) return super.getService(type, algorithm)
if (type == "Mac" && algorithm.equals("AESCMAC", ignoreCase = true)) return super.getService(type, algorithm)
return bc?.getService(type, algorithm)
}
override fun getServices(): MutableSet<Service> {
val s = (bc?.getServices() ?: emptySet<Service>()).toMutableSet()
s.addAll(super.getServices())
return s
}
}
}

View File

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

View File

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

@@ -0,0 +1,119 @@
package com.example.androidbackupgui.backup
import android.util.Log
import java.io.File
/**
* Manages [ResticRestBridge] lifecycle: create, start, stop, clean cache.
*
* Usage:
* ```kotlin
* bridgeRunner.withBridge(backend, url, user, pass, share, domain, repoPath) { bridgeUrl ->
* // RESTIC_REPOSITORY = bridgeUrl
* restic commands go here
* }
* // bridge stopped + cache cleaned automatically
* ```
*/
class RestBridgeRunner {
private val TAG = "RestBridgeRunner"
/** Cached transport to reuse SMB sessions across bridge instances. */
private var cachedTransport: RemoteTransport? = null
private var cachedTransportKey: String? = null
/**
* Start a REST bridge for the given [backend], execute [block] with the
* bridge URL, then stop and clean up.
*
* For [backend] == "local", the bridge is not started and [block] receives
* `null`.
*/
suspend fun <T> withBridge(
backend: String,
backendUrl: String,
backendUser: String,
backendPass: String,
backendShare: String,
backendDomain: String,
repoPath: String,
cacheDir: File,
transportFactory: (
backend: String,
url: String,
user: String,
pass: String,
share: String,
domain: String
) -> RemoteTransport? = ::createTransport,
block: suspend (bridgeUrl: String) -> T
): T {
if (backend == "local") {
return block(repoPath)
}
// Reuse cached transport (same SMB session) for consistent cross-bridge visibility
val key = "$backend|$backendUrl|$backendUser|$backendShare|$backendDomain"
if (cachedTransportKey != key) {
cachedTransport?.let { Log.d(TAG, "discarding stale cached transport") }
val t = transportFactory(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain)
?: return block(repoPath)
cachedTransport = t
cachedTransportKey = key
}
val transport = cachedTransport!!
val remoteBase = buildRemoteBase(backend, backendUrl, backendShare, repoPath)
val bridge = ResticRestBridge(transport, remoteBase, repoPath, cacheDir)
try {
bridge.start(0)
val port = bridge.listeningPort
if (port < 0) {
throw IllegalStateException("REST bridge failed to bind a port")
}
val bridgeUrl = "rest:http://127.0.0.1:$port/$repoPath"
Log.i(TAG, "REST bridge started on port $port for $remoteBase")
return block(bridgeUrl)
} 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()
}
}
}
/** Build the remote base path for the REST bridge. */
private fun buildRemoteBase(
backend: String,
backendUrl: String,
backendShare: String,
repoPath: String
): String {
return when (backend) {
"smb" -> "smb://${backendUrl.trimEnd('/')}/$backendShare/$repoPath"
"webdav" -> "${backendUrl.trimEnd('/')}/${repoPath.trimStart('/')}"
else -> repoPath
}
}
companion object {
/** Default transport factory: delegates to [RemoteTransport.create]. */
fun createTransport(
backend: String,
url: String,
user: String,
pass: String,
share: String,
domain: String
): RemoteTransport? {
return RemoteTransport.create(backend, url, user, pass, share, domain)
}
}
}

View File

@@ -4,24 +4,27 @@ import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlin.coroutines.coroutineContext
import com.example.androidbackupgui.backup.AppError
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.err
import java.io.File
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
private val resticJson = Json { ignoreUnknownKeys = true }
/**
* Backup operations: running restic backup and parsing its summary output.
*
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
* [RemoteSyncManager] which are shared across sub-modules.
* [RestBridgeRunner] which are shared across sub-modules.
*/
class ResticBackup(
private val runner: ResticCommandRunner,
private val envResolver: ResticEnvResolver,
private val syncManager: RemoteSyncManager
private val bridgeRunner: RestBridgeRunner
) {
private val TAG = "ResticWrapper"
private val TAG = "ResticBackup"
var cacheDir: String = ""
var backendDomain: String = ""
// ── Backup ─────────────────────────────────────────
@@ -36,51 +39,119 @@ class ResticBackup(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
): Result<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
): AppResult<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = true,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
if (backend == "local") {
val args = mutableListOf("backup", "--json")
for (path in paths) args.add(path)
for (tag in tags) { args.add("--tag"); args.add(tag) }
if (hostname != null) { args.add("--host"); args.add(hostname) }
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runResticStreaming(env, args) { line ->
if (!coroutineContext.isActive) return@runResticStreaming
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") emit(progress)
} catch (_: Exception) { /* ignore non-JSON lines */ }
}
if (result.exitCode != 0) {
return@withRemoteSync Result.failure(Exception("restic backup failed: ${result.stderr}"))
} catch (_: Exception) { }
}
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 ->
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 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) { }
}
if (result.exitCode != 0) return@withBridge err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
parseBackupSummary(result.stdout)
}
}
}
// ── Streaming backup (stdin) ──────────────────────
/**
* Run restic backup in --stdin mode, reading tar data from [stdinFile] (FIFO).
* [extraPaths] are files/directories backed up alongside the streaming data
* (e.g. APK paths, metadata directory).
*/
suspend fun backupStdin(
repoPath: String,
password: String,
stdinFile: File,
extraPaths: List<String>,
tags: List<String> = emptyList(),
hostname: String? = null,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
): AppResult<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
val args = mutableListOf("backup", "--json", "--stdin", "--stdin-filename", "app_data.tar")
for (path in extraPaths) args.add(path)
for (tag in tags) { args.add("--tag"); args.add(tag) }
if (hostname != null) { args.add("--host"); args.add(hostname) }
if (backend == "local") {
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runResticWithStdin(env, args, stdinFile) { line ->
if (!coroutineContext.isActive) return@runResticWithStdin
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") emit(progress)
} catch (_: Exception) { }
}
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)
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) { }
}
if (result.exitCode != 0) return@withBridge err(AppError.Restic("restic stream backup 失败", result.exitCode, result.stderr))
parseBackupSummary(result.stdout)
}
}
}
// ── Internal helpers ───────────────────────────────
/** Parse the JSON summary from the end of restic backup output. */
private fun parseBackupSummary(stdout: String): Result<ResticWrapper.BackupSummary> {
private fun parseBackupSummary(stdout: String): AppResult<ResticWrapper.BackupSummary> {
val lines = stdout.lines()
for (i in lines.indices.reversed()) {
val line = lines[i].trim()
if (!line.startsWith("{")) continue
try {
val summary = resticJson.decodeFromString<ResticWrapper.BackupSummary>(line)
if (summary.snapshotId.isNotEmpty()) return Result.success(summary)
if (summary.messageType == "summary" && summary.snapshotId.isNotEmpty()) return AppResult.Success(summary)
} catch (_: Exception) { /* keep looking */ }
}
return Result.failure(Exception("No summary found in restic output"))
return err(AppError.Parse("restic 备份输出未找到摘要信息", "stdout=" + stdout.length))
}
}

View File

@@ -18,10 +18,8 @@ object ResticBinary {
synchronized(this) {
if (cacheInit) return cachedBinaryPath
val nativeLibDir = context.applicationInfo.nativeLibraryDir
Log.d(TAG, "nativeLibraryDir = $nativeLibDir")
val path = File(nativeLibDir, BINARY_NAME)
Log.d(TAG, "restic: exists=${path.isFile} len=${path.length()} canExec=${path.canExecute()}")
Log.d(TAG, "nativeLibraryDir=$nativeLibDir exists=${path.isFile} len=${path.length()} canExec=${path.canExecute()}")
cachedBinaryPath = if (path.isFile) {
Log.i(TAG, "librestic.so ready at ${path.absolutePath} (${path.length()} bytes)")
@@ -35,13 +33,6 @@ object ResticBinary {
}
}
/** Get the temp directory used as local restic repo for remote backends. */
fun getTempRepoDir(context: Context): String {
val dir = File(context.cacheDir, "restic_remote_repo")
dir.mkdirs()
Log.d(TAG, "tempRepoDir = ${dir.absolutePath}")
return dir.absolutePath
}
fun isReady(): Boolean = false // call prepare() instead
fun isReady(): Boolean = cachedBinaryPath != null
}

View File

@@ -3,9 +3,13 @@ package com.example.androidbackupgui.backup
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import com.example.androidbackupgui.backup.AppError
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.File
import kotlin.coroutines.coroutineContext
import java.io.ByteArrayOutputStream
import java.io.InputStream
import kotlinx.serialization.Serializable
/**
@@ -27,43 +31,53 @@ class ResticCommandRunner {
)
/** Build the full command list to run restic. */
fun buildCommandArgs(args: List<String>): List<String> {
val cmd = listOf(binaryPath) + args
Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args cmd=$cmd")
return cmd
}
fun buildCommandArgs(args: List<String>): List<String> =
(listOf(binaryPath) + args).also { cmd ->
Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args -> cmd=$cmd")
}
/** Run restic (non-streaming). */
fun runRestic(env: Map<String, String>, args: List<String>): CommandResult {
val cmdArgs = buildCommandArgs(args)
Log.i(TAG, "runRestic cmd=${cmdArgs.joinToString(" ")}")
Log.d(TAG, "runRestic REPOSITORY=${env["RESTIC_REPOSITORY"]}")
// NOTE: Do NOT log RESTIC_PASSWORD or any value derived from it.
// RESTIC_REPOSITORY is safe to log (does not contain secrets).
env["TMPDIR"]?.let { File(it).mkdirs() }
return try {
val pb = ProcessBuilder(cmdArgs)
pb.environment().putAll(env)
pb.redirectErrorStream(false)
val process = pb.start()
val stderrText = StringBuilder()
val stderrThread = Thread({
try {
process.errorStream.bufferedReader().use { reader ->
var line: String?
while (reader.readLine().also { line = it } != null) {
Log.d(TAG, "restic stderr: $line")
stderrText.appendLine(line)
}
}
} catch (_: Exception) {}
}, "restic-stderr").apply { isDaemon = true; start() }
val stdout = process.inputStream.bufferedReader().use(BufferedReader::readText)
val exitCode = process.waitFor()
stderrThread.join(5000)
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()
}
} catch (_: Exception) { -1 }
val stderrText = stderrBytes.decodeToString()
Log.i(TAG, "runRestic exitCode=$exitCode stdout_len=${stdout.length}")
if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText}")
CommandResult(stdout.trim(), stderrText.toString().trim(), exitCode)
if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText.trim()}")
CommandResult(stdout.trim(), stderrText.trim(), exitCode)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "runRestic exception", e)
CommandResult("", e.message ?: "Unknown error", -1)
@@ -84,6 +98,7 @@ class ResticCommandRunner {
val cmdArgs = buildCommandArgs(args)
Log.i(TAG, "runResticStreaming cmd=${cmdArgs.joinToString(" ")}")
Log.d(TAG, "runResticStreaming REPOSITORY=${env["RESTIC_REPOSITORY"]}")
env["TMPDIR"]?.let { File(it).mkdirs() }
var process: Process? = null
try {
@@ -94,28 +109,22 @@ class ResticCommandRunner {
val stdoutText = StringBuilder()
val reader = process.inputStream.bufferedReader()
val stderrReader = process.errorStream.bufferedReader()
val stderrText = StringBuilder()
val stderrThread = Thread({
try { stderrReader.use { stderrText.append(it.readText()) } } catch (_: Exception) {}
}, "restic-stderr").apply { isDaemon = true; start() }
try {
var line: String?
var line: String
while (reader.readLine().also { line = it } != null) {
if (!coroutineContext.isActive) {
process.destroy()
break
}
val l = line!!
stdoutText.appendLine(l)
onLine(l)
stdoutText.appendLine(line)
onLine(line)
}
} finally {
try { reader.close() } catch (_: Exception) {}
}
stderrThread.join(5000)
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
@@ -140,11 +149,112 @@ class ResticCommandRunner {
Log.i(TAG, "runResticStreaming exitCode=$exitCode stdout_len=${stdoutText.length}")
if (stderrText.isNotEmpty()) Log.w(TAG, "runResticStreaming stderr: ${stderrText}")
CommandResult(stdoutText.toString().trim(), stderrText.toString().trim(), exitCode)
CommandResult(stdoutText.toString().trim(), stderrText.trim(), exitCode)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "runResticStreaming exception", e)
try { process?.destroy() } catch (_: Exception) {}
CommandResult("", e.message ?: "Unknown error", -1)
}
}
/**
* Run restic with stdin redirected from [stdinFile] (FIFO or regular file).
* Calls [onLine] for each stdout line (for streaming progress).
*/
suspend fun runResticWithStdin(
env: Map<String, String>,
args: List<String>,
stdinFile: File,
onLine: suspend (String) -> Unit
): CommandResult = withContext(Dispatchers.IO) {
val cmdArgs = buildCommandArgs(args)
Log.i(TAG, "runResticWithStdin cmd=${cmdArgs.joinToString(" ")} stdin=${stdinFile.absolutePath}")
Log.d(TAG, "runResticWithStdin REPOSITORY=${env["RESTIC_REPOSITORY"]}")
env["TMPDIR"]?.let { File(it).mkdirs() }
var process: Process? = null
try {
val pb = ProcessBuilder(cmdArgs)
pb.environment().putAll(env)
pb.redirectErrorStream(false)
process = pb.start()
// Pipe stdin from file to process on a daemon thread (API 24 compat)
Thread {
try {
val fis = java.io.FileInputStream(stdinFile)
val pos = process!!.outputStream
fis.use { input -> pos.use { output -> input.copyTo(output) } }
} catch (_: Exception) {
// FIFO writer closed; stdin pipe ends naturally
}
}.apply { isDaemon = true; start() }
val stdoutText = StringBuilder()
val reader = process.inputStream.bufferedReader()
try {
var line: String
while (reader.readLine().also { line = it } != null) {
if (!coroutineContext.isActive) {
process.destroy()
break
}
stdoutText.appendLine(line)
onLine(line)
}
} finally {
try { reader.close() } catch (_: Exception) {}
}
val stderrBytes = try { process.errorStream.use { it.readAllBytesCompat() } } catch (_: Exception) { byteArrayOf() }
val stderrText = stderrBytes.decodeToString().trim()
val exitCode = try {
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()
}
} catch (_: Exception) { -1 }
Log.i(TAG, "runResticWithStdin exitCode=$exitCode stdout_len=${stdoutText.length}")
if (stderrText.isNotEmpty()) Log.w(TAG, "runResticWithStdin stderr: ${stderrText}")
CommandResult(stdoutText.toString().trim(), stderrText.trim(), exitCode)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "runResticWithStdin exception", e)
try { process?.destroy() } catch (_: Exception) {}
CommandResult("", e.message ?: "Unknown error", -1)
}
}
}
/**
* Compat implementation of InputStream.readAllBytes() for API < 33.
* Reads the entire stream into a byte array.
*/
private fun InputStream.readAllBytesCompat(): ByteArray {
val buffer = ByteArrayOutputStream()
val data = ByteArray(4096)
while (true) {
val n = read(data)
if (n == -1) break
buffer.write(data, 0, n)
}
return buffer.toByteArray()
}

View File

@@ -5,30 +5,39 @@ package com.example.androidbackupgui.backup
*/
class ResticEnvResolver {
/** Build environment for restic. For SMB/WebDAV backends, uses local temp dir as repo. */
fun buildFullEnv(
repoPath: String,
/** Build environment for non-local backends using the REST bridge URL. */
fun buildBridgeEnv(
password: String,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
tempRepoDir: String = ""
bridgeUrl: String,
cacheDir: String
): Map<String, String> {
val env = HashMap(System.getenv() ?: emptyMap())
env["RESTIC_REPOSITORY"] = if (backend == "smb" || backend == "webdav") {
tempRepoDir
} else {
buildRepoUrl(backend, repoPath, backendUrl)
}
env["RESTIC_REPOSITORY"] = bridgeUrl
env["RESTIC_PASSWORD"] = password
// Restic needs HOME for its cache on Android (no $HOME by default).
// Both local and remote backends use the same cache dir (sibling of tempRepoDir).
if (tempRepoDir.isNotEmpty()) {
val cacheDir = tempRepoDir.substringBeforeLast("/") + "/restic_cache"
if (cacheDir.isNotEmpty()) {
env["HOME"] = cacheDir
env["XDG_CACHE_HOME"] = cacheDir
val tmpDir = "$cacheDir/restic_tmp"
env["TMPDIR"] = tmpDir
}
return env
}
/** Build environment for local repository. */
fun buildLocalEnv(
repoPath: String,
password: String,
cacheDir: String
): Map<String, String> {
val env = HashMap(System.getenv() ?: emptyMap())
env["RESTIC_REPOSITORY"] = repoPath
env["RESTIC_PASSWORD"] = password
if (cacheDir.isNotEmpty()) {
env["HOME"] = cacheDir
env["XDG_CACHE_HOME"] = cacheDir
val tmpDir = "$cacheDir/restic_tmp"
env["TMPDIR"] = tmpDir
}
return env
}

View File

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

View File

@@ -2,20 +2,35 @@ package com.example.androidbackupgui.backup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.example.androidbackupgui.backup.AppError
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.err
import java.io.File
/**
* Repository maintenance operations: prune, check, stats.
*
* [prune] requires both download and upload (it removes pack files from the remote).
* [check] and [stats] are download-only read operations.
*
* For remote backends, uses [RestBridgeRunner] to serve the backend via REST,
* so restic always sees a local rest-server repository. For local backends,
* operates directly on the repo path.
*
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
* [RemoteSyncManager] which are shared across sub-modules.
* [RestBridgeRunner] which are shared across sub-modules.
*/
class ResticMaintenance(
private val runner: ResticCommandRunner,
private val envResolver: ResticEnvResolver,
private val syncManager: RemoteSyncManager
private val bridgeRunner: RestBridgeRunner
) {
/** Cache directory for restic env and bridge temp files. Set by [ResticWrapper]. */
var cacheDir: String = ""
/** SMB NTLM domain for remote backend. Set by [ResticWrapper]. */
var backendDomain: String = ""
// ── Prune ──────────────────────────────────────────
suspend fun prune(
@@ -26,19 +41,23 @@ class ResticMaintenance(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> =
): AppResult<String> =
withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = true,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
if (backend == "local") {
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runRestic(env, "prune")
if (result.exitCode == 0) Result.success(result.stdout)
else Result.failure(Exception("restic prune failed: ${result.stderr}"))
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr))
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
val result = runner.runRestic(env, "prune")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr))
}
}
}
@@ -52,19 +71,23 @@ class ResticMaintenance(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> =
): AppResult<String> =
withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = false,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
if (backend == "local") {
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runRestic(env, "check")
if (result.exitCode == 0) Result.success(result.stdout)
else Result.failure(Exception("restic check failed: ${result.stderr}"))
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic check 失败", result.exitCode, result.stderr))
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
val result = runner.runRestic(env, "check")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic check 失败", result.exitCode, result.stderr))
}
}
}
@@ -78,19 +101,23 @@ class ResticMaintenance(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> =
): AppResult<String> =
withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = false,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
if (backend == "local") {
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runRestic(env, "stats")
if (result.exitCode == 0) Result.success(result.stdout)
else Result.failure(Exception("restic stats failed: ${result.stderr}"))
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic stats 失败", result.exitCode, result.stderr))
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
val result = runner.runRestic(env, "stats")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic stats 失败", result.exitCode, result.stderr))
}
}
}
}

View File

@@ -3,20 +3,33 @@ package com.example.androidbackupgui.backup
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.example.androidbackupgui.backup.AppError
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.err
import java.io.File
/**
* Repository lifecycle operations: init and repo URL construction.
*
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
* [RemoteSyncManager] which are shared across sub-modules.
* [RestBridgeRunner] which are shared across sub-modules.
*
* For "local" backends, invokes restic directly against [repoPath].
* For remote backends (SMB/WebDAV/rest-server), starts a temporary REST bridge
* via [RestBridgeRunner.withBridge] and points restic at the bridge URL.
*/
class ResticRepoInit(
private val runner: ResticCommandRunner,
private val envResolver: ResticEnvResolver,
private val syncManager: RemoteSyncManager
private val bridgeRunner: RestBridgeRunner
) {
private val TAG = "ResticWrapper"
/** Cache directory for restic env and bridge temp files. Set by ResticWrapper. */
var cacheDir: String = ""
/** NTLM domain for SMB authentication. Set by ResticWrapper. */
var backendDomain: String = ""
// ── Repository initialization ──────────────────────
suspend fun init(
@@ -27,38 +40,95 @@ class ResticRepoInit(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<Unit> =
): AppResult<Unit> =
withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = true,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
val result = runner.runRestic(env, "init")
// exitCode 0 = brand new repo created, needs upload
if (result.exitCode == 0) {
return@withRemoteSync Result.success(Unit)
if (backend == "local") {
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
runInit(env)
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
runInit(env)
}
// exitCode 1 = config already exists; verify the repo is actually usable
if (result.exitCode == 1) {
val verify = runner.runRestic(env, "snapshots", "--json")
if (verify.exitCode == 0) {
// Repo is healthy — already initialized with matching password
Log.i(TAG, "init: repo already initialized and verified")
return@withRemoteSync Result.success(Unit)
}
// Config exists but repo is corrupted (wrong password, missing keys, etc.)
return@withRemoteSync Result.failure(
Exception("仓库已存在但无法验证: ${verify.stderr.ifEmpty { "密码错误或密钥缺失" }}。请删除远端仓库后重试。")
)
}
Result.failure(Exception("restic init failed: ${result.stderr}"))
}
}
/** Shared init logic: run restic init, verify on exitCode 1. */
private suspend fun runInit(env: Map<String, String>): AppResult<Unit> {
val result = runner.runRestic(env, "init")
// exitCode 0 = brand new repo created
if (result.exitCode == 0) {
return AppResult.Success(Unit)
}
// exitCode 1: check if it's "config already exists" or a real error
if (result.exitCode == 1) {
if (!isConfigExistsError(result.stderr)) {
// Exit code 1 from restic can also mean connection/backend errors (500, timeout, etc.)
return err(AppError.Restic("restic init 失败: ${result.stderr.take(300).trim()}", result.exitCode, result.stderr))
}
var verify = runner.runRestic(env, "snapshots", "--json")
if (verify.exitCode == 0) {
// Repo is healthy — already initialized with matching password
Log.i(TAG, "init: repo already initialized and verified")
return AppResult.Success(Unit)
}
// Lock-related failure → try unlock then retry
if (isLockError(verify.stderr)) {
Log.w(TAG, "init: stale lock detected, running unlock")
runner.runRestic(env, "unlock")
verify = runner.runRestic(env, "snapshots", "--json")
if (verify.exitCode == 0) {
Log.i(TAG, "init: repo verified after unlock")
return AppResult.Success(Unit)
}
}
// Config exists but verification failed — diagnose the cause
val detail = diagnoseInitFailure(verify.stderr)
return err(
AppError.Restic("仓库已存在但无法验证: $detail", verify.exitCode, verify.stderr)
)
}
return err(AppError.Restic("restic init 失败", result.exitCode, result.stderr))
}
/** Check if [restic init]'s stderr indicates config already exists (vs a real error). */
private fun isConfigExistsError(stderr: String): Boolean {
val lower = stderr.lowercase()
return lower.contains("already exists") ||
lower.contains("config file already exists")
}
/** Check if stderr indicates a stale repository lock. */
private fun isLockError(stderr: String): Boolean {
val lower = stderr.lowercase()
return lower.contains("lock") ||
lower.contains("unable to create") ||
lower.contains("already locked")
}
/** Parse restic stderr to produce a user-facing diagnosis string. */
private fun diagnoseInitFailure(stderr: String): String {
val lower = stderr.lowercase()
return when {
lower.contains("wrong password") ||
lower.contains("password is incorrect") ||
lower.contains("unable to decrypt") ||
lower.contains("wrong key") ||
lower.contains("invalid password") ||
lower.contains("decryption") -> "密码不正确,请确认仓库密码"
lower.contains("key") && (lower.contains("not found") || lower.contains("missing")) ->
"密钥文件缺失,仓库可能已损坏"
lower.contains("permission") || lower.contains("access denied") ->
"权限不足,请检查目录权限"
lower.contains("not a directory") || lower.contains("no such file") ->
"仓库路径无效或不可访问"
else -> "仓库可能已损坏或密码不正确(${stderr.take(200).trim()}"
}
}
// ── Public URL helper ──────────────────────────────
/** Build a display-friendly repository URL for UI. */

View File

@@ -0,0 +1,387 @@
package com.example.androidbackupgui.backup
import android.util.Log
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoHTTPD.IHTTPSession
import kotlinx.coroutines.runBlocking
import java.io.ByteArrayInputStream
import java.io.File
import java.util.UUID
/**
* NanoHTTPD-based REST bridge implementing the restic REST backend API.
*
* Translates restic HTTP requests into [RemoteTransport] calls so that restic
* can read/write blobs directly to SMB/WebDAV without a local staging repo.
*
* Port is auto-assigned (0); use [listeningPort] after start().
*
* @param repoPath repository path from the bridge URL (e.g. "backup").
* Stripped from incoming URIs so that the remoteBase SMB path
* does not get double-nested with the repo prefix.
*/
class ResticRestBridge(
private val transport: RemoteTransport,
private val remoteBase: String,
private val repoPath: String,
private val cacheDir: File
) : NanoHTTPD(0) {
private val TAG = "ResticRestBridge"
init {
cacheDir.mkdirs()
}
@Suppress("DEPRECATION")
override fun serve(session: IHTTPSession): Response {
val uri = session.uri
val method = session.method
val headers = session.headers
val params = session.parms
Log.d(TAG, "$method $uri")
return try {
handleRequest(method, uri, headers, params, session)
} catch (e: Exception) {
Log.e(TAG, "request failed: $method $uri", e)
newFixedLengthResponse(
Response.Status.INTERNAL_ERROR,
"text/plain",
e.message ?: "Internal error"
)
}
}
private fun handleRequest(
method: NanoHTTPD.Method,
uri: String,
headers: Map<String, String>,
params: Map<String, String>,
session: IHTTPSession
): Response {
val path = uri.trimEnd('/')
// Strip the repoPath prefix (/backup/...) from the URI so that type/name
// parsing sees only the restic REST API segment.
val stripPrefix = if (repoPath.isNotEmpty()) "/${repoPath.trim('/')}" else ""
val strippedPath = if (stripPrefix.isNotEmpty() && path.startsWith(stripPrefix)) {
path.removePrefix(stripPrefix).ifEmpty { "/" }
} else {
path
}
// POST {path}?create=true -> mkdirs
if (method == NanoHTTPD.Method.POST && params["create"] == "true") {
return runBlocking {
when (transport.mkdirs(remoteBase)) {
is AppResult.Success -> newFixedLengthResponse(
Response.Status.OK, "text/plain", ""
)
is AppResult.Failure -> newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain", "mkdirs failed"
)
}
}
}
val segments = strippedPath.split("/").filter { it.isNotEmpty() }
if (segments.isEmpty()) {
return newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Invalid path")
}
val firstSegment = segments.first()
// /config endpoints
if (firstSegment == "config" && segments.size == 1) {
return handleConfig(method, headers, session)
}
// /{type}/ or /{type}/{name}
val type = firstSegment
val name = if (segments.size >= 2) segments.drop(1).joinToString("/") else null
if (name == null) {
if (method == NanoHTTPD.Method.GET) {
return handleListBlobs(type)
}
return newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "text/plain", "")
}
return when (method) {
NanoHTTPD.Method.HEAD -> handleHeadBlob(type, name)
NanoHTTPD.Method.GET -> handleGetBlob(type, name, headers)
NanoHTTPD.Method.POST -> handlePostBlob(type, name, session)
NanoHTTPD.Method.DELETE -> handleDeleteBlob(type, name)
else -> newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "text/plain", "")
}
}
// -- Config endpoints -------------------------------------------
/**
* Stream body from session input to a temp file to avoid OOM on large blobs.
* Returns the temp file (caller must delete).
*/
private fun streamBodyToFile(session: IHTTPSession, tmpDir: File): Result<File> {
val started = System.currentTimeMillis()
return try {
val tmpFile = File(tmpDir, "restic_blob_${UUID.randomUUID()}")
val contentLength = session.headers["content-length"]?.toLongOrNull() ?: -1L
val input = (session as NanoHTTPD.HTTPSession).inputStream
Log.d(TAG, "streamBodyToFile: reading body (content-length=$contentLength)...")
tmpFile.outputStream().use { output ->
if (contentLength > 0) {
// Read exactly Content-Length bytes to avoid blocking on keep-alive
val buf = ByteArray(8192)
var remaining = contentLength
while (remaining > 0) {
val toRead = minOf(buf.size.toLong(), remaining).toInt()
val n = input.read(buf, 0, toRead)
if (n == -1) break
output.write(buf, 0, n)
remaining -= n
}
if (remaining > 0) {
Log.w(TAG, "streamBodyToFile: body truncated, expected $contentLength bytes but got EOF after ${contentLength - remaining}")
}
Unit
} else {
input.copyTo(output)
}
}
val elapsed = System.currentTimeMillis() - started
val bytes = tmpFile.length()
Log.i(TAG, "streamBodyToFile: read $bytes bytes in ${elapsed}ms")
Result.success(tmpFile)
} catch (e: Exception) {
val elapsed = System.currentTimeMillis() - started
Log.w(TAG, "streamBodyToFile failed after ${elapsed}ms", e)
Result.failure(e)
}
}
@Suppress("UNUSED_PARAMETER")
private fun handleConfig(
method: NanoHTTPD.Method,
headers: Map<String, String>,
session: IHTTPSession
): Response = runBlocking {
val remotePath = "$remoteBase/config"
when (method) {
NanoHTTPD.Method.HEAD -> {
when (val exists = transport.exists(remotePath)) {
is AppResult.Success -> {
if (exists.data) {
val sizeResult = transport.fileSize(remotePath)
val fileSize = if (sizeResult is AppResult.Success) sizeResult.data else 0L
newFixedLengthResponse(
Response.Status.OK, "application/octet-stream",
ByteArrayInputStream(ByteArray(0)), fileSize
)
} else {
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "")
}
}
is AppResult.Failure -> newFixedLengthResponse(
Response.Status.NOT_FOUND, "text/plain", ""
)
}
}
NanoHTTPD.Method.GET -> {
val tempFile = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
try {
when (transport.download(remotePath, tempFile.absolutePath)) {
is AppResult.Success -> {
val data = tempFile.readBytes()
newFixedLengthResponse(Response.Status.OK, "application/octet-stream", data.inputStream(), data.size.toLong())
}
is AppResult.Failure -> newFixedLengthResponse(
Response.Status.NOT_FOUND, "text/plain", ""
)
}
} finally {
tempFile.delete()
}
}
NanoHTTPD.Method.POST -> {
val tmpResult = streamBodyToFile(session, cacheDir)
if (tmpResult.isFailure) return@runBlocking newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain",
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}"
)
val tmpFile = tmpResult.getOrThrow()
try {
when (transport.upload(tmpFile.absolutePath, remotePath)) {
is AppResult.Success -> newFixedLengthResponse(
Response.Status.OK, "text/plain", ""
)
is AppResult.Failure -> newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain", "upload failed"
)
}
} finally {
tmpFile.delete()
}
}
else -> newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "text/plain", "")
}
}
// -- Blob listing -----------------------------------------------
private fun handleListBlobs(type: String): Response = runBlocking {
val remoteDir = "$remoteBase/$type"
when (val result = transport.listFiles(remoteDir)) {
is AppResult.Success -> {
val items = result.data
val json = buildV2Json(items)
newFixedLengthResponse(Response.Status.OK, "application/vnd.x.restic.rest.v2", json)
}
is AppResult.Failure -> newFixedLengthResponse(
Response.Status.NOT_FOUND, "text/plain", ""
)
}
}
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()
}
// -- Blob HEAD (exists + size) ----------------------------------
private fun handleHeadBlob(type: String, name: String): Response = runBlocking {
val remotePath = "$remoteBase/$type/$name"
when (val result = transport.exists(remotePath)) {
is AppResult.Success -> {
if (result.data) {
newFixedLengthResponse(Response.Status.OK, "application/octet-stream", "")
} else {
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "")
}
}
is AppResult.Failure -> newFixedLengthResponse(
Response.Status.NOT_FOUND, "text/plain", ""
)
}
}
// -- Blob GET (download with optional Range) --------------------
private fun handleGetBlob(
type: String,
name: String,
headers: Map<String, String>
): Response = runBlocking {
val remotePath = "$remoteBase/$type/$name"
// Use RandomAccessFile to avoid loading entire blob into memory
val tempFile = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
try {
when (transport.download(remotePath, tempFile.absolutePath)) {
is AppResult.Success -> {
val rangeHeader = headers["range"]?.lowercase()
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
// Range request — only works with known file size
val fileLen = tempFile.length()
val range = rangeHeader.removePrefix("bytes=").trim()
val dashIdx = range.indexOf('-')
val start = range.substring(0, if (dashIdx >= 0) dashIdx else range.length)
.toLongOrNull() ?: 0L
val end = if (dashIdx >= 0 && dashIdx + 1 < range.length) {
range.substring(dashIdx + 1).toLongOrNull() ?: (fileLen - 1)
} else {
fileLen - 1
}
val actualEnd = minOf(end, fileLen - 1).coerceAtLeast(0)
val actualStart = minOf(start, actualEnd).coerceAtLeast(0)
val chunkSize = (actualEnd - actualStart + 1).toInt()
val chunk = ByteArray(chunkSize)
try {
val raf = java.io.RandomAccessFile(tempFile, "r")
raf.use { it.seek(actualStart); it.readFully(chunk) }
} catch (_: Exception) {
return@runBlocking newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain", "range read failed"
)
}
val response = newChunkedResponse(
Response.Status.PARTIAL_CONTENT,
"application/octet-stream",
chunk.inputStream()
)
response.addHeader("Content-Range", "bytes $actualStart-$actualEnd/$fileLen")
response.addHeader("Content-Length", chunkSize.toString())
return@runBlocking response
}
// Full file — read into memory (blobs are typically small)
val data = tempFile.readBytes()
val response = newChunkedResponse(
Response.Status.OK,
"application/octet-stream",
data.inputStream()
)
response.addHeader("Content-Length", data.size.toString())
response
}
is AppResult.Failure -> newFixedLengthResponse(
Response.Status.NOT_FOUND, "text/plain", ""
)
}
} finally {
tempFile.delete()
}
}
// -- Blob POST (upload) -----------------------------------------
private fun handlePostBlob(
type: String,
name: String,
session: IHTTPSession
): Response = runBlocking {
val remotePath = "$remoteBase/$type/$name"
val tmpResult = streamBodyToFile(session, cacheDir)
if (tmpResult.isFailure) return@runBlocking newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain",
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}"
)
val tmpFile = tmpResult.getOrThrow()
try {
when (transport.upload(tmpFile.absolutePath, remotePath)) {
is AppResult.Success -> newFixedLengthResponse(
Response.Status.OK, "text/plain", ""
)
is AppResult.Failure -> newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain", "upload failed"
)
}
} finally {
tmpFile.delete()
}
}
// -- Blob DELETE ------------------------------------------------
private fun handleDeleteBlob(type: String, name: String): Response = runBlocking {
val remotePath = "$remoteBase/$type/$name"
when (transport.delete(remotePath)) {
is AppResult.Success -> newFixedLengthResponse(
Response.Status.OK, "text/plain", ""
)
is AppResult.Failure -> newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain", "delete failed"
)
}
}
}

View File

@@ -4,26 +4,42 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import kotlinx.serialization.json.Json
import kotlin.coroutines.coroutineContext
import com.example.androidbackupgui.backup.AppError
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.err
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
private val resticJson = Json { ignoreUnknownKeys = true }
/**
* Restore operations: full directory restore and single-file dump.
*
* Both are download-only operations (no upload to remote needed).
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
* [RemoteSyncManager] which are shared across sub-modules.
* [RestBridgeRunner] which are shared across sub-modules.
*
* @property cacheDir Cache directory for restic env and bridge temp files; set by [ResticWrapper].
* @property backendDomain Domain for SMB NTLM authentication; set by [ResticWrapper].
*/
class ResticRestore(
private val runner: ResticCommandRunner,
private val envResolver: ResticEnvResolver,
private val syncManager: RemoteSyncManager
private val bridgeRunner: RestBridgeRunner
) {
/** Cache directory for restic env and bridge temp files. Set by [ResticWrapper]. */
var cacheDir: String = ""
/** Domain for SMB NTLM authentication. Set by [ResticWrapper]. */
var backendDomain: String = ""
// ── Restore ────────────────────────────────────────
/**
* Restore a snapshot to [targetPath], optionally filtered by [include] pattern.
*
* For local backends, builds env via [ResticEnvResolver.buildLocalEnv] and runs
* restic restore directly. For remote backends, proxies through [RestBridgeRunner]
* using a local REST bridge, building env via [ResticEnvResolver.buildBridgeEnv].
*/
suspend fun restore(
repoPath: String,
password: String,
@@ -35,22 +51,17 @@ class ResticRestore(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
onProgress: suspend (String) -> Unit = {}
): Result<Unit> = withContext(Dispatchers.IO) {
): AppResult<Unit> = withContext(Dispatchers.IO) {
val emit: suspend (String) -> Unit = { s -> withContext(Dispatchers.Main) { onProgress(s) } }
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = false,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
if (backend == "local") {
File(targetPath).mkdirs()
val args = mutableListOf("restore", snapshotId, "--target", targetPath, "--json")
if (include != null) { args.add("--include"); args.add(include) }
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runResticStreaming(env, args) { line ->
if (!coroutineContext.isActive) return@runResticStreaming
try {
@@ -67,13 +78,50 @@ class ResticRestore(
} catch (_: Exception) { emit(line) }
}
if (result.exitCode == 0) Result.success(Unit)
else Result.failure(Exception("restic restore failed: ${result.stderr}"))
if (result.exitCode == 0) AppResult.Success(Unit)
else err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr))
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare, backendDomain,
repoPath, File(cacheDir)
) { bridgeUrl ->
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 result = runner.runResticStreaming(env, args) { line ->
if (!coroutineContext.isActive) return@runResticStreaming
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
when (progress.messageType) {
"status" -> {
val percent = "%.1f".format(progress.percentDone * 100)
emit("恢复进度: $percent%")
}
"summary" -> {
emit("恢复完成: ${progress.totalFiles} 个文件")
}
}
} catch (_: Exception) { emit(line) }
}
if (result.exitCode == 0) AppResult.Success(Unit)
else err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr))
}
}
}
// ── File dump ──────────────────────────────────────
/**
* Dump the contents of a single file from a snapshot.
*
* For local backends, builds env via [ResticEnvResolver.buildLocalEnv] and runs
* restic dump directly. For remote backends, proxies through [RestBridgeRunner]
* using a local REST bridge, building env via [ResticEnvResolver.buildBridgeEnv].
*/
suspend fun dump(
repoPath: String,
password: String,
@@ -83,19 +131,23 @@ class ResticRestore(
backendUrl: String = "",
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> = withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = false,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
backendShare: String = ""
): AppResult<String> = withContext(Dispatchers.IO) {
if (backend == "local") {
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runRestic(env, "dump", snapshotId, filePath)
if (result.exitCode == 0) Result.success(result.stdout)
else Result.failure(Exception(result.stderr.ifEmpty { "restic dump failed with exit code ${result.exitCode}" }))
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare, backendDomain,
repoPath, File(cacheDir)
) { bridgeUrl ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
val result = runner.runRestic(env, "dump", snapshotId, filePath)
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))
}
}
}
}

View File

@@ -2,24 +2,34 @@ package com.example.androidbackupgui.backup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import com.example.androidbackupgui.backup.AppError
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.err
import java.io.File
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
private val resticJson = Json { ignoreUnknownKeys = true }
/**
* Snapshot listing and retention policy operations.
*
* [listSnapshots] is download-only; [forget] requires both download and upload
* (forget removes snapshots from the remote).
* [listSnapshots] is download-only; [forget] removes snapshots from the remote.
*
* For "local" backends, invokes restic directly against [repoPath].
* For remote backends (SMB/WebDAV/rest-server), starts a temporary REST bridge
* via [RestBridgeRunner.withBridge] and points restic at the bridge URL.
*
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
* [RemoteSyncManager] which are shared across sub-modules.
* [RestBridgeRunner] which are shared across sub-modules.
*/
class ResticSnapshotOps(
private val runner: ResticCommandRunner,
private val envResolver: ResticEnvResolver,
private val syncManager: RemoteSyncManager
private val bridgeRunner: RestBridgeRunner
) {
/** Cache directory for restic env and bridge temp files. Set by ResticWrapper. */
var cacheDir: String = ""
/** NTLM domain for SMB authentication. Set by ResticWrapper. */
var backendDomain: String = ""
// ── List snapshots ─────────────────────────────────
suspend fun listSnapshots(
@@ -31,31 +41,49 @@ class ResticSnapshotOps(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<List<ResticWrapper.ResticSnapshot>> = withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = false,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
): AppResult<List<ResticWrapper.ResticSnapshot>> = withContext(Dispatchers.IO) {
if (backend == "local") {
val args = mutableListOf("snapshots", "--json")
if (tag != null) { args.add("--tag"); args.add(tag) }
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runRestic(env, args)
if (result.exitCode != 0) {
return@withRemoteSync Result.failure(Exception("restic snapshots failed: ${result.stderr}"))
return@withContext err(AppError.Restic("restic snapshots 失败", result.exitCode, result.stderr))
}
try {
val snapshots = resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
result.stdout.ifEmpty { "[]" }
)
Result.success(snapshots.sortedByDescending { it.time })
AppResult.Success(snapshots.sortedByDescending { it.time })
} catch (e: Exception) {
Result.failure(Exception("Failed to parse snapshot JSON: ${e.message}"))
err(AppError.Parse("解析快照 JSON 失败", e.message ?: ""))
}
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl ->
val args = mutableListOf("snapshots", "--json")
if (tag != null) { args.add("--tag"); args.add(tag) }
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
val result = runner.runRestic(env, args)
if (result.exitCode != 0) {
return@withBridge err(AppError.Restic("restic snapshots 失败", result.exitCode, result.stderr))
}
try {
val snapshots = resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
result.stdout.ifEmpty { "[]" }
)
AppResult.Success(snapshots.sortedByDescending { it.time })
} catch (e: Exception) {
err(AppError.Parse("解析快照 JSON 失败", e.message ?: ""))
}
}
}
}
@@ -74,14 +102,8 @@ class ResticSnapshotOps(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> = withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = true,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
): AppResult<String> = withContext(Dispatchers.IO) {
if (backend == "local") {
val args = mutableListOf(
"forget",
"--keep-daily", keepDaily.toString(),
@@ -90,11 +112,30 @@ class ResticSnapshotOps(
)
if (dryRun) args.add("--dry-run")
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runRestic(env, args)
if (result.exitCode == 0) Result.success(result.stdout)
else Result.failure(Exception("restic forget failed: ${result.stderr}"))
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic forget 失败", result.exitCode, result.stderr))
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl ->
val args = mutableListOf(
"forget",
"--keep-daily", keepDaily.toString(),
"--keep-weekly", keepWeekly.toString(),
"--keep-monthly", keepMonthly.toString()
)
if (dryRun) args.add("--dry-run")
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
val result = runner.runRestic(env, args)
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic forget 失败", result.exitCode, result.stderr))
}
}
}
}

View File

@@ -5,9 +5,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import java.io.File
import kotlinx.coroutines.withContext
import org.json.JSONObject
import kotlin.coroutines.coroutineContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName
import com.example.androidbackupgui.backup.AppError
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.err
/**
* Wraps the restic CLI binary for backup/restore operations.
@@ -15,13 +19,14 @@ import kotlinx.serialization.SerialName
* Uses environment variables (RESTIC_REPOSITORY, RESTIC_PASSWORD) rather than
* command-line flags to avoid leaking secrets in the process list.
*
* For SMB/WebDAV backends, restic runs against a local temp directory;
* RemoteTransport syncs files to/from the remote backend.
* For SMB/WebDAV backends, restic connects via a local REST bridge
* ([ResticRestBridge]) that translates HTTP requests to [RemoteTransport] calls,
* eliminating the need for a local staging repo and full-directory sync.
*
* All public methods are suspend and run on Dispatchers.IO.
*
* This object is a facade that delegates to [ResticCommandRunner],
* [ResticEnvResolver], [RemoteSyncManager], and sub-module classes
* [ResticEnvResolver], [RestBridgeRunner], and sub-module classes
* ([ResticRepoInit], [ResticBackup], [ResticRestore], [ResticSnapshotOps],
* [ResticMaintenance]).
*/
@@ -31,15 +36,15 @@ object ResticWrapper {
private val runner = ResticCommandRunner()
private val envResolver = ResticEnvResolver()
private val syncManager = RemoteSyncManager()
private val bridgeRunner = RestBridgeRunner()
// ── Sub-module instances ───────────────────────────
private val repoInit = ResticRepoInit(runner, envResolver, syncManager)
private val backupOp = ResticBackup(runner, envResolver, syncManager)
private val restoreOp = ResticRestore(runner, envResolver, syncManager)
private val snapshotOps = ResticSnapshotOps(runner, envResolver, syncManager)
private val maintenance = ResticMaintenance(runner, envResolver, syncManager)
private val repoInit = ResticRepoInit(runner, envResolver, bridgeRunner)
private val backupOp = ResticBackup(runner, envResolver, bridgeRunner)
private val restoreOp = ResticRestore(runner, envResolver, bridgeRunner)
private val snapshotOps = ResticSnapshotOps(runner, envResolver, bridgeRunner)
private val maintenance = ResticMaintenance(runner, envResolver, bridgeRunner)
// ── Property delegation ───────────────────────────
@@ -48,16 +53,28 @@ object ResticWrapper {
get() = runner.binaryPath
set(v) { runner.binaryPath = v }
/** Local temp directory used as restic repo for SMB/WebDAV backends. */
var tempRepoDir: String
get() = syncManager.tempRepoDir
set(v) { syncManager.tempRepoDir = v }
/** Cache directory for restic (XDG_CACHE_HOME) and bridge tmp blobs. */
var cacheDir: String = ""
set(v) {
field = v
repoInit.cacheDir = v
backupOp.cacheDir = v
restoreOp.cacheDir = v
snapshotOps.cacheDir = v
maintenance.cacheDir = v
}
/** Domain for SMB NTLM authentication. */
var backendDomain: String
get() = syncManager.backendDomain
set(v) { syncManager.backendDomain = v }
/** Domain for SMB NTLM authentication. Propagated to sub-modules. */
var backendDomain: String = ""
set(v) {
field = v
repoInit.backendDomain = v
backupOp.backendDomain = v
restoreOp.backendDomain = v
snapshotOps.backendDomain = v
maintenance.backendDomain = v
}
// ── Progress data ─────────────────────────────────
@Serializable
@@ -81,6 +98,13 @@ object ResticWrapper {
val hostname: String = ""
)
/** App metadata read from a restic snapshot for change detection. */
data class SnapshotAppInfo(
val label: String,
val isSystem: Boolean,
val apkSizes: List<Long> = emptyList()
)
// ── Repository lifecycle ─────────────────────────
suspend fun init(
@@ -91,30 +115,28 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<Unit> = repoInit.init(
repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
): AppResult<Unit> = repoInit.init(
repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare
)
// ── Backup ─────────────────────────────────────────
@Serializable
data class BackupSummary(
@SerialName("message_type") val messageType: String = "",
@SerialName("snapshot_id") val snapshotId: String,
@SerialName("files_new") val filesNew: Int,
@SerialName("files_changed") val filesChanged: Int,
@SerialName("files_unmodified") val filesUnmodified: Int,
@SerialName("dirs_new") val dirsNew: Int,
@SerialName("dirs_changed") val dirsChanged: Int,
@SerialName("dirs_unmodified") val dirsUnmodified: Int,
@SerialName("data_blobs") val dataBlobs: Int,
@SerialName("tree_blobs") val treeBlobs: Int,
@SerialName("data_added") val dataAdded: Long,
@SerialName("total_files_processed") val totalFilesProcessed: Int,
@SerialName("total_bytes_processed") val totalBytesProcessed: Long,
@SerialName("total_duration") val totalDuration: Double
@SerialName("files_new") val filesNew: Int = 0,
@SerialName("files_changed") val filesChanged: Int = 0,
@SerialName("files_unmodified") val filesUnmodified: Int = 0,
@SerialName("dirs_new") val dirsNew: Int = 0,
@SerialName("dirs_changed") val dirsChanged: Int = 0,
@SerialName("dirs_unmodified") val dirsUnmodified: Int = 0,
@SerialName("data_blobs") val dataBlobs: Int = 0,
@SerialName("tree_blobs") val treeBlobs: Int = 0,
@SerialName("data_added") val dataAdded: Long = 0,
@SerialName("total_files_processed") val totalFilesProcessed: Int = 0,
@SerialName("total_bytes_processed") val totalBytesProcessed: Long = 0,
@SerialName("total_duration") val totalDuration: Double = 0.0
)
suspend fun backup(
@@ -128,13 +150,32 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
onProgress: suspend (ResticProgress) -> Unit = {}
): Result<BackupSummary> = backupOp.backup(
): AppResult<BackupSummary> = backupOp.backup(
repoPath, password, paths, tags, hostname,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress, onProgress
onProgress
)
// ── Streaming backup (stdin) ─────────────────────
suspend fun backupStdin(
repoPath: String,
password: String,
stdinFile: File,
extraPaths: List<String>,
tags: List<String> = emptyList(),
hostname: String? = null,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onProgress: suspend (ResticProgress) -> Unit = {}
): AppResult<BackupSummary> = backupOp.backupStdin(
repoPath, password, stdinFile, extraPaths, tags, hostname,
backend, backendUrl, backendUser, backendPass, backendShare,
onProgress
)
// ── Restore ────────────────────────────────────────
@@ -150,13 +191,11 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
onProgress: suspend (String) -> Unit = {}
): Result<Unit> = restoreOp.restore(
): AppResult<Unit> = restoreOp.restore(
repoPath, password, snapshotId, targetPath, include,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress, onProgress
onProgress
)
// ── File dump ──────────────────────────────────────
@@ -171,12 +210,9 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> = restoreOp.dump(
): AppResult<String> = restoreOp.dump(
repoPath, password, snapshotId, filePath,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
backend, backendUrl, backendUser, backendPass, backendShare
)
// ── Snapshot management ────────────────────────────
@@ -190,12 +226,9 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<List<ResticSnapshot>> = snapshotOps.listSnapshots(
): AppResult<List<ResticSnapshot>> = snapshotOps.listSnapshots(
repoPath, password, tag,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
backend, backendUrl, backendUser, backendPass, backendShare
)
suspend fun forget(
@@ -210,14 +243,81 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> = snapshotOps.forget(
): AppResult<String> = snapshotOps.forget(
repoPath, password, keepDaily, keepWeekly, keepMonthly, dryRun,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
backend, backendUrl, backendUser, backendPass, backendShare
)
/**
* Read [app_details.json] from the latest restic snapshot and return a map
* of package-name → [SnapshotAppInfo]. Returns `null` when no snapshots
* exist or the file cannot be read (e.g. first backup, legacy format).
*/
suspend fun getLatestSnapshotAppDetails(
repoPath: String,
password: String,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
): Map<String, SnapshotAppInfo>? = withContext(Dispatchers.IO) {
val snapsResult = snapshotOps.listSnapshots(
repoPath, password, tag = null,
backend, backendUrl, backendUser, backendPass, backendShare
)
val snaps = when (snapsResult) {
is AppResult.Failure -> {
Log.w(TAG, "getLatestSnapshotAppDetails: listSnapshots failed: ${snapsResult.error.message}")
null
}
is AppResult.Success -> snapsResult.data
} ?: return@withContext null
if (snaps.isEmpty()) return@withContext null
val latestId = snaps.first().shortId
val basePath = snaps.first().paths.firstOrNull()?.trimEnd('/') ?: return@withContext null
val dumpResult = restoreOp.dump(
repoPath, password, latestId, "$basePath/app_details.json",
backend, backendUrl, backendUser, backendPass, backendShare
)
val jsonStr = when (dumpResult) {
is AppResult.Failure -> return@withContext null
is AppResult.Success -> dumpResult.data
}
return@withContext parseAppDetailsJson(jsonStr)
}
/** Parse [app_details.json] content into a package-name → [SnapshotAppInfo] map. */
internal fun parseAppDetailsJson(jsonStr: String): Map<String, SnapshotAppInfo> {
val map = mutableMapOf<String, SnapshotAppInfo>()
try {
val root = JSONObject(jsonStr)
for (key in root.keys()) {
val entry = root.optJSONObject(key) ?: continue
val sizes = mutableListOf<Long>()
val sizesArr = entry.optJSONArray("apkSizes")
if (sizesArr != null) {
for (i in 0 until sizesArr.length()) {
sizes.add(sizesArr.optLong(i, 0L))
}
}
map[key] = SnapshotAppInfo(
label = entry.optString("label", key),
isSystem = entry.optBoolean("isSystem", false),
apkSizes = sizes
)
}
} catch (_: Exception) {
Log.w(TAG, "parseAppDetailsJson: failed to parse JSON")
}
return map
}
// ── Maintenance ────────────────────────────────────
suspend fun prune(
@@ -228,12 +328,9 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> = maintenance.prune(
): AppResult<String> = maintenance.prune(
repoPath, password,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
backend, backendUrl, backendUser, backendPass, backendShare
)
suspend fun check(
@@ -244,12 +341,9 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> = maintenance.check(
): AppResult<String> = maintenance.check(
repoPath, password,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
backend, backendUrl, backendUser, backendPass, backendShare
)
suspend fun stats(
@@ -260,12 +354,9 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> = maintenance.stats(
): AppResult<String> = maintenance.stats(
repoPath, password,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
backend, backendUrl, backendUser, backendPass, backendShare
)
// ── Public URL helper ──────────────────────────────
@@ -274,14 +365,4 @@ object ResticWrapper {
fun buildRepoUrl(backend: String, repoPath: String, backendUrl: String): String {
return repoInit.buildRepoUrl(backend, repoPath, backendUrl)
}
// ── Lifecycle ──────────────────────────────────────
/**
* Public safety-net cleanup called by fragment lifecycle.
* Waits for any in-progress operation to finish, then deletes temp dirs.
*/
suspend fun cleanup() {
syncManager.cleanup()
}
}

View File

@@ -1,6 +1,8 @@
package com.example.androidbackupgui.backup
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
@@ -18,6 +20,8 @@ import kotlinx.serialization.Serializable
*/
object RestoreOperation {
private const val TAG = "RestoreOperation"
@Serializable
data class RestoreProgress(
val current: Int,
@@ -39,6 +43,7 @@ object RestoreOperation {
* @param filterPkgs if non-null, only restore packages in this set
*/
suspend fun restoreApps(
context: Context,
backupDir: File,
userId: String = "0",
filterPkgs: Set<String>? = null,
@@ -47,6 +52,11 @@ object RestoreOperation {
val emit: suspend (RestoreProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
val startTime = System.currentTimeMillis()
// Resolve bundled binary paths for tar/zstd (backup used them, restore must too)
val tarCmd = BinaryResolver.tarPath(context) ?: "tar"
val bundledZstd = BinaryResolver.zstdPath(context)
val zstdCmd = bundledZstd ?: "zstd"
// Read app list from backup
val appListFile = File(backupDir, "appList.txt")
val allPackages = if (appListFile.exists()) {
@@ -66,6 +76,7 @@ object RestoreOperation {
} else {
allPackages
}
LogUtil.i(TAG, "restoreApps: starting restore of ${packages.size} packages from ${backupDir.absolutePath}")
val successAtomic = AtomicInteger(0)
val failAtomic = AtomicInteger(0)
@@ -84,7 +95,7 @@ object RestoreOperation {
// 1. Install APK
emit(RestoreProgress(index + 1, packages.size, pkg, "install", "正在安装 APK…"))
val installed = installApk(appBackupDir)
val installed = installApk(pkg, appBackupDir)
if (!installed) {
failAtomic.incrementAndGet()
@@ -97,11 +108,11 @@ object RestoreOperation {
// 3. Restore data
emit(RestoreProgress(index + 1, packages.size, pkg, "data", "正在恢复数据…"))
restoreData(appBackupDir)
restoreData(pkg, userId, appBackupDir, tarCmd, zstdCmd)
// 4. Restore OBB
emit(RestoreProgress(index + 1, packages.size, pkg, "obb", "正在恢复 OBB…"))
restoreObb(pkg, appBackupDir)
restoreObb(pkg, appBackupDir, tarCmd, zstdCmd)
// 5. Restore SSAID
emit(RestoreProgress(index + 1, packages.size, pkg, "ssaid", "正在恢复 SSAID…"))
@@ -122,10 +133,13 @@ object RestoreOperation {
}
val elapsed = System.currentTimeMillis() - startTime
RestoreResult(successAtomic.get(), failAtomic.get(), elapsed)
val successCount = successAtomic.get()
val failCount = failAtomic.get()
LogUtil.i(TAG, "restoreApps: completed — success=$successCount fail=$failCount elapsed=${elapsed}ms")
RestoreResult(successCount, failCount, elapsed)
}
private suspend fun installApk(appDir: File): Boolean {
private suspend fun installApk(packageName: String, appDir: File): Boolean {
// Find APK files
val apkFiles = appDir.listFiles()
?.filter { it.name.endsWith(".apk") }
@@ -134,53 +148,132 @@ object RestoreOperation {
if (apkFiles.isEmpty()) return false
// Build install command for multiple APKs (split APK support)
val apkPaths = apkFiles.joinToString(" ") { "'${it.absolutePath.shellEscape()}'" }
suspend fun doInstall(): Boolean {
// Build install command for multiple APKs (split APK support)
val apkPaths = apkFiles.joinToString(" ") { "'${it.absolutePath.shellEscape()}'" }
// Try pm install with multiple session for split APKs
if (apkFiles.size > 1) {
val result = RootShell.exec("pm install-create -r -t 2>/dev/null")
val sessionId = result.output.lines()
.firstOrNull { it.contains("Success") }
?.substringAfter("[")
?.substringBefore("]")
// Try pm install with multiple session for split APKs
if (apkFiles.size > 1) {
val result = RootShell.exec("pm install-create -r -t 2>/dev/null")
val sessionId = result.output.lines()
.firstOrNull { it.contains("Success") }
?.substringAfter("[")
?.substringBefore("]")
if (sessionId != null) {
for ((i, apk) in apkFiles.withIndex()) {
val sessionName = if (i == 0) "base.apk" else "split_${i}.apk"
RootShell.exec("pm install-write '${sessionId.shellEscape()}' '$sessionName' '${apk.absolutePath.shellEscape()}'")
if (sessionId != null) {
for ((i, apk) in apkFiles.withIndex()) {
val sessionName = if (i == 0) "base.apk" else "split_${i}.apk"
RootShell.exec("pm install-write '${sessionId.shellEscape()}' '$sessionName' '${apk.absolutePath.shellEscape()}'")
}
val commit = RootShell.exec("pm install-commit '${sessionId.shellEscape()}'")
return commit.isSuccess
}
val commit = RootShell.exec("pm install-commit '${sessionId.shellEscape()}'")
return commit.isSuccess
}
// Single APK install
val result = RootShell.exec("pm install -r -t $apkPaths")
return result.isSuccess
}
// Single APK install
val result = RootShell.exec("pm install -r -t $apkPaths")
return result.isSuccess
suspend fun isInstalled(): Boolean {
val verifyResult = RootShell.exec("pm list packages '${packageName.shellEscape()}' 2>/dev/null")
return verifyResult.output.contains(packageName)
}
// First install attempt
val firstOk = doInstall()
if (!firstOk) {
Log.e(TAG, "installApk: $packageName — first install attempt failed")
return false
}
// Verify installation succeeded
if (isInstalled()) {
Log.i(TAG, "installApk: $packageName installed and verified")
return true
}
Log.w(TAG, "installApk: $packageName installed but not detected — retrying once")
val retryOk = doInstall()
if (!retryOk) {
Log.e(TAG, "installApk: $packageName — retry install failed")
return false
}
if (isInstalled()) {
Log.i(TAG, "installApk: $packageName installed and verified (after retry)")
return true
}
Log.e(TAG, "installApk: $packageName — install reported success but package not found after retry")
return false
}
private suspend fun restoreData(appDir: File) {
private suspend fun restoreData(packageName: String, userId: String, appDir: File, tarCmd: String, zstdCmd: String) {
val files = appDir.listFiles()
if (files.isNullOrEmpty()) {
Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}")
return
}
val dataFiles = files.filter { it.name.contains("_data.tar") }
if (dataFiles.isEmpty()) {
Log.w(TAG, "restoreData: no _data.tar in ${appDir.name}, found: ${files.map { it.name }}")
return
}
// Find data archive
val dataFiles = appDir.listFiles()
?.filter { it.name.contains("_data.tar") }
?: return
// Build exclusion patterns for cache/temp directories
val dataPaths = listOf("/data/data/$packageName", "/data/user_de/$userId/$packageName")
val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup")
val excludeArgs = dataPaths.flatMap { dataPath ->
excludeFolders.flatMap { folder ->
listOf("--exclude='${dataPath.shellEscape()}/$folder'", "--exclude='${dataPath.shellEscape()}/$folder/*'")
}
}.joinToString(" ")
for (archive in dataFiles) {
val archivePath = archive.absolutePath.shellEscape()
// Verify archive doesn't contain path traversal before extracting
if (!isArchiveSafe(archive)) continue
when {
archive.name.endsWith(".zst") -> {
RootShell.exec("zstd -d -c '$archivePath' | tar -xf - -C / 2>/dev/null")
}
archive.name.endsWith(".gz") -> {
RootShell.exec("tar -xzf '$archivePath' -C / 2>/dev/null")
}
archive.name.endsWith(".tar") -> {
RootShell.exec("tar -xf '$archivePath' -C / 2>/dev/null")
}
Log.d(TAG, "restoreData: found archive ${archive.name}")
if (!isArchiveSafe(archive, zstdCmd)) {
Log.w(TAG, "restoreData: archive NOT SAFE, skipping: ${archive.name}")
continue
}
// Build the extract command with exclusion flags
val baseCmd = when {
archive.name.endsWith(".zst") ->
"set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null"
archive.name.endsWith(".gz") ->
"$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null"
archive.name.endsWith(".tar") ->
"$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null"
else -> { Log.w(TAG, "restoreData: unknown archive type ${archive.name}"); continue }
}
val result = RootShell.exec(baseCmd)
if (result.isSuccess) {
Log.i(TAG, "restoreData: extracted ${archive.name}")
} else {
Log.e(TAG, "restoreData: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}")
// Continue to try SELinux fix even if extraction had issues
}
}
// Restore SELinux context on extracted data directories
for (dataPath in dataPaths) {
// Try to get the existing context (if the path already existed)
val existingContext = SELinuxUtil.getContext(dataPath)
val context = existingContext ?: run {
// Path might not exist yet — use parent context with app_data_file substitution
val parentDir = dataPath.substringBeforeLast("/")
val parentContext = SELinuxUtil.getContext(parentDir)
parentContext?.replace("system_data_file", "app_data_file")
}
if (context != null) {
Log.d(TAG, "restoreData: restoring SELinux context on $dataPath: $context")
SELinuxUtil.chcon(context, dataPath)
} else {
Log.w(TAG, "restoreData: could not determine SELinux context for $dataPath")
}
}
}
@@ -190,13 +283,18 @@ object RestoreOperation {
* or symbolic links pointing outside the tree.
* Accepts both absolute and relative paths — tar implementations vary.
*/
private suspend fun isArchiveSafe(archive: File): Boolean {
private suspend fun isArchiveSafe(archive: File, zstdCmd: String = "zstd"): Boolean {
val listCmd = if (archive.name.endsWith(".zst")) {
"zstd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null"
"set -o pipefail; $zstdCmd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null"
} else {
"tar tf '${archive.absolutePath.shellEscape()}' 2>/dev/null"
}
val result = RootShell.exec(listCmd)
var result = RootShell.exec(listCmd)
// Fallback: try without pipefail (some Android shells don't support it)
if (!result.isSuccess && archive.name.endsWith(".zst")) {
val fallbackCmd = "$zstdCmd -d -c '${archive.absolutePath.shellEscape()}' 2>/dev/null | tar tf - 2>/dev/null"
result = RootShell.exec(fallbackCmd)
}
if (!result.isSuccess) return false
return !result.output.lines().any { line ->
val path = line.substringBefore(" -> ")
@@ -208,77 +306,50 @@ object RestoreOperation {
}
}
private suspend fun restoreObb(packageName: String, appDir: File) {
private suspend fun restoreObb(packageName: String, appDir: File, tarCmd: String, zstdCmd: String) {
val obbFiles = appDir.listFiles()
?.filter { it.name.contains("_obb.tar") }
?: return
if (obbFiles.isEmpty()) return
// Build exclusion patterns for OBB cache/temp directories
val obbPath = "/storage/emulated/0/Android/obb/$packageName"
val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup", "Backup_*")
val excludeArgs = excludeFolders.joinToString(" ") { "--exclude='${obbPath.shellEscape()}/$it' --exclude='${obbPath.shellEscape()}/$it/*'" }
for (archive in obbFiles) {
if (!isArchiveSafe(archive)) continue
if (!isArchiveSafe(archive, zstdCmd)) continue
val archivePath = archive.absolutePath.shellEscape()
when {
archive.name.endsWith(".zst") -> {
RootShell.exec("zstd -d -c '$archivePath' | tar -xf - -C / 2>/dev/null")
RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null")
}
archive.name.endsWith(".gz") -> {
RootShell.exec("tar -xzf '$archivePath' -C / 2>/dev/null")
RootShell.exec("$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null")
}
archive.name.endsWith(".tar") -> {
RootShell.exec("tar -xf '$archivePath' -C / 2>/dev/null")
RootShell.exec("$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null")
}
}
}
// Fix OBB permissions
RootShell.exec("chown -R 1023:1023 /storage/emulated/0/Android/obb/${packageName.shellEscape()}/ 2>/dev/null")
// Fix OBB permissions: resolve GID from parent directory instead of hardcoding 1023
val gidResult = RootShell.exec("stat -c %g '${obbPath.shellEscape()}' 2>/dev/null")
val gid = gidResult.output.trim().toIntOrNull() ?: 1023 // fallback to media_rw gid
RootShell.exec("chown -R $gid:$gid '${obbPath.shellEscape()}/' 2>/dev/null")
Log.i(TAG, "restoreObb: set ownership to $gid:$gid on $obbPath")
}
private suspend fun restoreSsaid(packageName: String, appDir: File, userId: String) {
val ssaidFile = File(appDir, "ssaid.txt")
if (!ssaidFile.exists()) return
val ssaidLine = ssaidFile.readText().trim()
if (ssaidLine.isBlank()) return
val ssaidValue = ssaidFile.readText().trim()
if (ssaidValue.isBlank()) return
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
val pkgEsc = packageName.shellEscape()
val ssaidEsc = ssaidLine.shellEscape()
// Remove existing entry for this package, insert new one before </settings>
RootShell.exec(
"grep -v '${pkgEsc}' '$targetFile' > '$targetFile.tmp' && " +
"sed -i '\$ i ${ssaidEsc}' '$targetFile.tmp' && " +
"mv '$targetFile.tmp' '$targetFile'"
)
}
private suspend fun restorePermissions(packageName: String, appDir: File) {
val permFile = File(appDir, "permissions.txt")
if (!permFile.exists()) return
val perms = permFile.readLines()
.filter { it.contains("granted=true") }
.mapNotNull { line ->
// Extract permission name from dumpsys output
// Format: "permission.name: granted=true" or similar
line.substringBefore(":")
.trim()
.takeIf { it.isNotEmpty() && it.contains(".") }
}
val pkgEsc = packageName.shellEscape()
for (perm in perms) {
val result = RootShell.exec("pm grant '$pkgEsc' '${perm.shellEscape()}' 2>&1")
if (!result.isSuccess) {
android.util.Log.w("RestoreOperation", "pm grant failed for $packageName: $perm${result.output}")
}
}
}
private suspend fun fixDataOwnership(packageName: String, userId: String) {
val pkgEsc = packageName.shellEscape()
val uidEsc = userId.shellEscape()
val uidResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1")
// Resolve the app's UID
val uidResult = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep 'userId=' | head -1")
val uid = uidResult.output
.substringAfter("userId=", "")
.substringBefore(" ")
@@ -286,11 +357,170 @@ object RestoreOperation {
.trim()
.toIntOrNull()
if (uid != null) {
RootShell.exec("chown -R $uid:$uid /data/data/$pkgEsc/ 2>/dev/null")
RootShell.exec("chown -R $uid:$uid /data/user_de/$uidEsc/$pkgEsc/ 2>/dev/null")
RootShell.exec("restorecon -R /data/data/$pkgEsc/ 2>/dev/null")
RootShell.exec("restorecon -R /data/user_de/$uidEsc/$pkgEsc/ 2>/dev/null")
if (uid == null) {
Log.w(TAG, "restoreSsaid: could not resolve UID for $packageName")
return
}
// Try XML-based approach first (more reliable across Android versions)
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
val xmlSuccess = run {
// Check if file exists
val checkResult = RootShell.exec("test -f '$targetFile' && echo 'exists'")
if (!checkResult.output.contains("exists")) {
Log.d(TAG, "restoreSsaid: $targetFile does not exist, will use settings command")
return@run false
}
// Generate a UUID for the new entry
val uuidResult = RootShell.exec("cat /proc/sys/kernel/random/uuid 2>/dev/null")
val id = uuidResult.output.trim()
if (id.length != 36) { // UUID format check
Log.w(TAG, "restoreSsaid: could not generate UUID (got '$id'), falling back")
return@run false
}
// Remove existing entry for this package and insert new one before </settings>
val manipCmd = buildString {
append("sed -i \"/package.*${packageName.shellEscape()}/d\" '$targetFile' && ")
append("sed -i \"s#</settings>#<setting id=\\\"$id\\\" package=\\\"${packageName.shellEscape()}\\\" value=\\\"${ssaidValue.shellEscape()}\\\" defaultValue=\\\"default\\\" />\\n</settings>#\" '$targetFile'")
}
val result = RootShell.exec(manipCmd)
if (!result.isSuccess) {
Log.w(TAG, "restoreSsaid: XML edit failed: ${result.error}")
return@run false
}
// Verify the package entry was added by checking if it appears in the file now
val verifyCmd = RootShell.exec("grep -c \"${packageName.shellEscape()}\" '$targetFile' 2>/dev/null")
val entryCount = verifyCmd.output.trim().toIntOrNull() ?: 0
if (entryCount > 0) {
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via XML (uid=$uid)")
true
} else {
Log.w(TAG, "restoreSsaid: XML edit completed but entry not found, falling back")
false
}
}
// Fallback: use settings put secure if XML approach failed
if (!xmlSuccess) {
val result = RootShell.exec("settings put secure ssaid_$uid '${ssaidValue.shellEscape()}'")
if (result.isSuccess) {
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via settings (uid=$uid)")
} else {
Log.e(TAG, "restoreSsaid: failed to set SSAID for $packageName: ${result.error}")
}
}
}
private suspend fun restorePermissions(packageName: String, appDir: File) {
val permFile = File(appDir, "permissions.txt")
if (!permFile.exists()) return
// Parse permissions from dumpsys output.
// Format: "android.permission.XXX: granted=true" or "android.permission.XXX: granted=false"
val parsedPerms = try {
permFile.readLines().mapNotNull { line ->
val name = line.substringBefore(":").trim().takeIf { it.isNotEmpty() && it.contains(".") } ?: return@mapNotNull null
val granted = line.contains("granted=true")
Pair(name, granted)
}
} catch (_: Exception) { emptyList() }
if (parsedPerms.isEmpty()) return
val pkgEsc = packageName.shellEscape()
// Reset app ops first (clears any previous modes)
RootShell.exec("appops reset '$pkgEsc' 2>/dev/null")
val grantedPerms = parsedPerms.filter { it.second }.map { it.first }
val deniedPerms = parsedPerms.filter { !it.second }.map { it.first }
// Grant runtime permissions that were previously granted
for (perm in grantedPerms) {
val result = RootShell.exec("pm grant '$pkgEsc' '${perm.shellEscape()}' 2>&1")
if (!result.isSuccess) {
Log.w(TAG, "restorePermissions: pm grant failed for $packageName: $perm${result.output}")
}
}
// Revoke runtime permissions that were explicitly denied
for (perm in deniedPerms) {
val result = RootShell.exec("pm revoke '$pkgEsc' '${perm.shellEscape()}' 2>&1")
if (!result.isSuccess) {
// Revoking a permission that isn't granted is not an error — just log at debug level
Log.d(TAG, "restorePermissions: pm revoke for $packageName: $perm${result.output}")
}
}
Log.i(TAG, "restorePermissions: ${grantedPerms.size} granted, ${deniedPerms.size} revoked for $packageName")
}
/** Resolve app UID using multiple methods for robustness across Android versions. */
private suspend fun resolveAppUid(packageName: String): Int? {
val pkgEsc = packageName.shellEscape()
// Method 1: pm list packages -U (reliable, consistent output format)
val pmResult = RootShell.exec("pm list packages -U 2>/dev/null | grep '${pkgEsc}$'")
val pmUid = pmResult.output
.substringAfter(" uid:")
.trim()
.toIntOrNull()
if (pmUid != null) return pmUid
// Method 2: dumpsys package (fallback for older Android)
val dsResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1")
val dsUid = dsResult.output
.substringAfter("userId=", "")
.substringBefore(" ")
.substringBefore(",")
.trim()
.toIntOrNull()
if (dsUid != null) return dsUid
// Method 3: dumpsys with userId: separator (AOSP variant)
val ds2Result = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId:' | head -1")
val ds2Uid = ds2Result.output
.substringAfter("userId:", "")
.substringBefore(" ")
.trim()
.toIntOrNull()
return ds2Uid
}
private suspend fun fixDataOwnership(packageName: String, userId: String) {
val pkgEsc = packageName.shellEscape()
val uidEsc = userId.shellEscape()
val uid = resolveAppUid(packageName)
if (uid == null) {
Log.w(TAG, "fixDataOwnership: could not resolve UID for $packageName — data will be inaccessible")
return
}
// USER and USER_DE use uid:uid (app's own group)
val dataPaths = listOf(
"/data/data/$pkgEsc",
"/data/user_de/$uidEsc/$pkgEsc"
)
for (dataPath in dataPaths) {
RootShell.exec("chown -R $uid:$uid '$dataPath/' 2>/dev/null")
// Restore SELinux context instead of using restorecon (which applies defaults)
val existingContext = SELinuxUtil.getContext(dataPath)
val context = existingContext ?: run {
val parentDir = dataPath.substringBeforeLast("/")
val parentContext = SELinuxUtil.getContext(parentDir)
parentContext?.replace("system_data_file", "app_data_file")
}
if (context != null) {
SELinuxUtil.chcon(context, dataPath)
Log.d(TAG, "fixDataOwnership: restored SELinux context on $dataPath: $context")
} else {
Log.w(TAG, "fixDataOwnership: could not determine SELinux context for $dataPath")
}
}
}
}

View File

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

View File

@@ -11,8 +11,10 @@ import jcifs.smb.SmbFileInputStream
import jcifs.smb.SmbFileOutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import java.io.File
import java.util.Properties
import java.util.concurrent.atomic.AtomicBoolean
class SmbTransport(
private val host: String,
@@ -21,10 +23,21 @@ class SmbTransport(
private val password: String,
private val domain: String = "",
private val bufferSize: Int = 8192,
private val smbSigning: Boolean = true
private val smbSigning: Boolean = false
): RemoteTransport {
companion object { private const val TAG = "SmbTransport" }
companion object {
private const val TAG = "SmbTransport"
/** Register missing JCA algorithms for jcifs-ng (MD4, AESCMAC, etc.). */
private val patchesRegistered = AtomicBoolean(false)
fun registerMissingAlgorithms() {
if (patchesRegistered.compareAndSet(false, true)) {
MissingAlgoProvider.register()
}
}
}
private val context: CIFSContext by lazy {
registerMissingAlgorithms()
val props = Properties().apply {
// Force SMB 2.0.2 minimum — SMB1 is disabled on modern Windows
setProperty("jcifs.smb.client.minVersion", "SMB202")
@@ -32,7 +45,7 @@ class SmbTransport(
// Shorter timeouts for Android
setProperty("jcifs.smb.client.responseTimeout", "15000")
setProperty("jcifs.smb.client.connTimeout", "10000")
// Enable SMB signing for security (prevents tampering) — disable for legacy servers
// SMB signing (disabled by default — most home servers don't support it)
if (smbSigning) {
setProperty("jcifs.smb.client.signingEnabled", "true")
setProperty("jcifs.smb.client.encryptionEnabled", "true")
@@ -46,7 +59,9 @@ class SmbTransport(
}
}
/** Build a full SMB URL. If [path] is already a full URL, pass through. */
private fun buildUrl(path: String): String {
if (path.startsWith("smb://")) return path
val cleanPath = path.trimStart('/')
val sharePath = if (share.isNotEmpty()) "$share/$cleanPath" else cleanPath
return "smb://$host/$sharePath"
@@ -54,7 +69,7 @@ class SmbTransport(
private fun smbFile(path: String): SmbFile = SmbFile(buildUrl(path), context)
override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): Result<Unit> =
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)
@@ -81,16 +96,28 @@ class SmbTransport(
}
}
}
// 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))
Log.i(TAG, "upload $localPath -> ${buildUrl(remotePath)} ($fileSize bytes)")
Result.success(Unit)
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "upload failed: ${buildUrl(remotePath)}", e)
Result.failure(Exception("SMB upload failed: ${e.message}", 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): Result<Unit> =
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 localFile = File(localPath)
@@ -114,19 +141,21 @@ class SmbTransport(
}
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
Log.d(TAG, "download ${buildUrl(remotePath)} -> $localPath (${localFile.length()} bytes)")
Result.success(Unit)
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "download failed: $remotePath", e)
Result.failure(Exception("SMB download failed: ${e.message}", e))
err(AppError.Remote("SMB 下载失败", "download", cause = e))
}
}
override suspend fun listFiles(remoteDir: String): Result<List<RemoteTransport.RemoteFileInfo>> =
override suspend fun listFiles(remoteDir: String): AppResult<List<RemoteTransport.RemoteFileInfo>> =
withContext(Dispatchers.IO) {
try {
val dir = smbFile(remoteDir)
if (!dir.exists() || !dir.isDirectory) {
return@withContext Result.failure(FileNotFoundException(remoteDir))
return@withContext err(AppError.Remote("远端路径不存在", "list", isNotFound = true))
}
// SmbFile.getName() in jcifs-ng 2.1.x is broken — it concatenates
// parent-dir + filename without separator. Use the URL to extract
@@ -154,66 +183,87 @@ class SmbTransport(
}
?: emptyList()
Log.d(TAG, "listFiles $remoteDir -> ${entries.size} entries: ${entries.joinToString { "${it.name}(${if (it.isDirectory) "d" else "f"},${it.size})" }}")
Result.success(entries)
AppResult.Success(entries)
} catch (e: SmbException) {
if (e.ntStatus == 0xC0000034.toInt()) {
return@withContext Result.failure(FileNotFoundException(remoteDir))
return@withContext err(AppError.Remote("远端路径不存在", "list", isNotFound = true))
}
Log.e(TAG, "listFiles failed: $remoteDir", e)
Result.failure(Exception("SMB list failed: ${e.message}", e))
err(AppError.Remote("SMB 列表失败", "list", cause = e))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "listFiles failed: $remoteDir", e)
Result.failure(Exception("SMB list failed: ${e.message}", e))
err(AppError.Remote("SMB 列表失败", "list", cause = e))
}
}
override suspend fun mkdirs(remotePath: String): Result<Unit> =
override suspend fun mkdirs(remotePath: String): AppResult<Unit> =
withContext(Dispatchers.IO) {
try {
val dir = smbFile(remotePath)
if (!dir.exists()) dir.mkdirs()
Result.success(Unit)
AppResult.Success(Unit)
} catch (e: SmbException) {
// STATUS_OBJECT_NAME_COLLISION (0xC0000035): directory already exists — not an error
if (e.ntStatus == 0xC0000035.toInt()) {
Result.success(Unit)
AppResult.Success(Unit)
} else {
Log.e(TAG, "mkdirs failed: $remotePath${e.message}")
Result.failure(Exception("SMB mkdirs failed: ${e.message}", e))
Log.e(TAG, "mkdirs failed: $remotePathntStatus=0x${e.ntStatus.toString(16)} msg=${e.message} cause=${e.cause}")
err(AppError.Remote("SMB 创建目录失败", "mkdirs", cause = e))
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "mkdirs failed: $remotePath${e.message}")
Result.failure(Exception("SMB mkdirs failed: ${e.message}", e))
Log.e(TAG, "mkdirs failed: $remotePath${e::class.java.name}: ${e.message} cause=${e.cause?.message}")
err(AppError.Remote("SMB 创建目录失败", "mkdirs", cause = e))
}
}
override suspend fun delete(remotePath: String): Result<Unit> =
override suspend fun delete(remotePath: String): AppResult<Unit> =
withContext(Dispatchers.IO) {
try {
val file = smbFile(remotePath)
if (file.exists()) file.delete()
Result.success(Unit)
AppResult.Success(Unit)
} catch (e: SmbException) {
// STATUS_OBJECT_NAME_NOT_FOUND (0xC0000034): file already gone — not an error
if (e.ntStatus == 0xC0000034.toInt()) {
Result.success(Unit)
AppResult.Success(Unit)
} else {
Log.w(TAG, "delete failed: $remotePath${e.message}")
Result.failure(Exception("SMB delete failed: ${e.message}", e))
err(AppError.Remote("SMB 删除失败", "delete", cause = e))
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "delete failed: $remotePath${e.message}")
Result.failure(Exception("SMB delete failed: ${e.message}", e))
err(AppError.Remote("SMB 删除失败", "delete", cause = e))
}
}
override suspend fun exists(remotePath: String): Result<Boolean> =
override suspend fun exists(remotePath: String): AppResult<Boolean> =
withContext(Dispatchers.IO) {
try {
Result.success(smbFile(remotePath).exists())
AppResult.Success(smbFile(remotePath).exists())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(Exception("SMB exists check failed: ${e.message}", e))
err(AppError.Remote("SMB 检查失败", "exists", cause = e))
}
}
override suspend fun fileSize(remotePath: String): AppResult<Long> =
withContext(Dispatchers.IO) {
try {
val file = smbFile(remotePath)
if (!file.exists()) return@withContext err(AppError.Remote("文件不存在", "fileSize"))
AppResult.Success(file.length())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
err(AppError.Remote("SMB 获取文件大小失败", "fileSize", cause = e))
}
}
}

View File

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

View File

@@ -6,6 +6,7 @@ import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
import com.thegrizzlylabs.sardineandroid.impl.SardineException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import java.io.ByteArrayOutputStream
import java.io.File
@@ -31,16 +32,14 @@ class WebdavTransport(
return "$baseUrl/$cleanPath"
}
override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): Result<Unit> =
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 Result.failure(
Exception("WebDAV upload: file too large (${fileSize / 1024 / 1024}MB), max 50MB")
)
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))
@@ -61,14 +60,16 @@ class WebdavTransport(
}
sardine.put(url, data, "application/octet-stream")
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
Result.success(Unit)
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "upload failed: $remotePath", e)
Result.failure(Exception("WebDAV upload failed: ${e.message}", 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): Result<Unit> =
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)
@@ -91,13 +92,15 @@ class WebdavTransport(
}
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
Log.d(TAG, "download $url -> $localPath (${localFile.length()} bytes)")
Result.success(Unit)
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "download failed: $remotePath", e)
Result.failure(Exception("WebDAV download failed: ${e.message}", e))
err(AppError.Remote("WebDAV 下载失败", "download", cause = e))
}
}
override suspend fun listFiles(remoteDir: String): Result<List<RemoteTransport.RemoteFileInfo>> =
override suspend fun listFiles(remoteDir: String): AppResult<List<RemoteTransport.RemoteFileInfo>> =
withContext(Dispatchers.IO) {
try {
val url = buildUrl(remoteDir)
@@ -116,7 +119,9 @@ class WebdavTransport(
isDirectory = it.isDirectory
) }
Log.d(TAG, "listFiles $remoteDir -> ${entries.size} entries")
Result.success(entries)
AppResult.Success(entries)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Only treat 404 as empty for non-root paths; the caller (listRemoteRecursive)
// handles the distinction. We propagate the error so the caller can decide.
@@ -124,14 +129,14 @@ class WebdavTransport(
if (is404) {
// Return a failure with a distinguishable marker so callers can check
Log.d(TAG, "listFiles $remoteDir -> 404 (not found)")
return@withContext Result.failure(FileNotFoundException(remoteDir))
return@withContext err(AppError.Remote("远端路径不存在", "list", isNotFound = true))
}
Log.e(TAG, "listFiles failed: $remoteDir", e)
Result.failure(Exception("WebDAV list failed: ${e.message}", e))
err(AppError.Remote("WebDAV 列表失败", "list", cause = e))
}
}
override suspend fun mkdirs(remotePath: String): Result<Unit> =
override suspend fun mkdirs(remotePath: String): AppResult<Unit> =
withContext(Dispatchers.IO) {
try {
val parts = remotePath.trimStart('/').split("/")
@@ -139,34 +144,55 @@ class WebdavTransport(
for (part in parts) {
current = if (current.isEmpty()) part else "$current/$part"
try { sardine.createDirectory(buildUrl(current)) }
catch (_: Exception) { /* already exists or parent missing, continue */ }
catch (_: Exception) { Log.w(TAG, "mkdirs: failed to create $current"); continue }
}
Result.success(Unit)
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "mkdirs failed: $remotePath${e.message}")
Result.success(Unit) // best-effort; upload will fail if dir can't be created
AppResult.Success(Unit) // best-effort; upload will fail if dir can't be created
}
}
override suspend fun delete(remotePath: String): Result<Unit> =
override suspend fun delete(remotePath: String): AppResult<Unit> =
withContext(Dispatchers.IO) {
try {
val url = buildUrl(remotePath)
sardine.delete(url)
Result.success(Unit)
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "delete failed (ignoring): $remotePath${e.message}")
Result.success(Unit)
err(AppError.Remote("WebDAV 删除失败", "delete", cause = e))
}
}
override suspend fun exists(remotePath: String): Result<Boolean> =
override suspend fun exists(remotePath: String): AppResult<Boolean> =
withContext(Dispatchers.IO) {
try {
val result = sardine.exists(buildUrl(remotePath))
Result.success(result)
AppResult.Success(result)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(Exception("WebDAV exists check failed: ${e.message}", e))
err(AppError.Remote("WebDAV 检查失败", "exists", cause = e))
}
}
override suspend fun fileSize(remotePath: String): AppResult<Long> =
withContext(Dispatchers.IO) {
try {
val url = buildUrl(remotePath)
if (!sardine.exists(url)) return@withContext err(AppError.Remote("文件不存在", "fileSize"))
val resources = sardine.list(url)
val size = resources.firstOrNull()?.contentLength ?: 0L
AppResult.Success(size)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
err(AppError.Remote("WebDAV 获取文件大小失败", "fileSize", cause = e))
}
}
}

View File

@@ -5,12 +5,15 @@ import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import android.util.Log
/**
* Backup and restore WiFi configuration.
* Mirrors backup_script WiFi backup/restore logic.
*/
object WifiManager {
private const val TAG = "WifiManager"
// Possible WiFi config paths on different Android versions
private val WIFI_PATHS = listOf(
@@ -57,21 +60,27 @@ object WifiManager {
// Try the most common path
val fallback = "/data/misc/apexdata/com.android.wifi/WifiConfigStore.xml"
val parent = File(fallback).parentFile?.absolutePath?.shellEscape() ?: return@withContext false
RootShell.exec("mkdir -p '$parent'")
val mkdirResult = RootShell.exec("mkdir -p '$parent'")
if (!mkdirResult.isSuccess) return@withContext false
val result = RootShell.exec("cp '$backupPath' '$fallback'")
if (!result.isSuccess) return@withContext false
RootShell.exec("chown system:wifi '$fallback'")
RootShell.exec("chmod 0660 '$fallback'")
val chownResult = RootShell.exec("chown system:wifi '$fallback'")
if (!chownResult.isSuccess) Log.w(TAG, "chown failed: ${chownResult.error}")
val chmodResult = RootShell.exec("chmod 0660 '$fallback'")
if (!chmodResult.isSuccess) Log.w(TAG, "chmod failed: ${chmodResult.error}")
} else {
val result = RootShell.exec("cp '$backupPath' '$wifiTarget'")
if (!result.isSuccess) return@withContext false
RootShell.exec("chown system:wifi '$wifiTarget'")
RootShell.exec("chmod 0660 '$wifiTarget'")
val chownResult = RootShell.exec("chown system:wifi '$wifiTarget'")
if (!chownResult.isSuccess) Log.w(TAG, "chown failed: ${chownResult.error}")
val chmodResult = RootShell.exec("chmod 0660 '$wifiTarget'")
if (!chmodResult.isSuccess) Log.w(TAG, "chmod failed: ${chmodResult.error}")
}
// WiFi backup only takes effect after reboot, but we can try reloading
RootShell.exec("svc wifi disable 2>/dev/null")
RootShell.exec("svc wifi enable 2>/dev/null")
// These are best-effort since reloading WiFi only takes full effect on reboot
true
}
}

View File

@@ -1,12 +1,12 @@
package com.example.androidbackupgui.root
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.*
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.io.InputStream
import android.util.Log
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
/**
* Escape a string for safe use inside single-quoted shell strings.
@@ -15,23 +15,16 @@ import android.util.Log
fun String.shellEscape(): String = this.replace("'", "'\\''")
/**
* Persistent root shell session via `su`.
* Manages a single su process and executes commands sequentially.
* Thread-safe via Mutex — all session state is guarded by the mutex.
* Root shell access via libsu.
* Shell.cmd internally manages su sessions, compatible with Magisk/KernelSU/APatch.
* All shell operations are thread-safe through coroutine dispatchers.
*/
object RootShell {
private var process: Process? = null
private var writer: OutputStreamWriter? = null
private var reader: BufferedReader? = null
private var errReader: BufferedReader? = null
private const val TAG = "RootShell"
/** Default command timeout in milliseconds. */
private const val COMMAND_TIMEOUT_MS = 120_000L
private val mutex = Mutex()
/** Result of a shell command execution. */
data class ShellResult(
val output: String,
@@ -41,134 +34,69 @@ object RootShell {
val isSuccess get() = exitCode == 0
}
/** Quick process-alive check. Caller MUST hold the mutex. */
private fun isAliveUnsafe(): Boolean {
val p = process ?: return false
return try { p.exitValue(); false } catch (_: IllegalThreadStateException) { true }
}
/**
* Open (or re-open) the su session and verify root access.
* Caller MUST hold the mutex.
* libsu shell initializer: enter global mount namespace via nsenter.
* Preserves the original PATH so that tar/zstd (from Termux etc.) remain accessible.
* Ref: DataBackup (XayahSuSuSu) uses the same nsenter pattern.
*/
private fun ensureSessionUnsafe(): Boolean {
if (isAliveUnsafe()) return true
return try {
val p = Runtime.getRuntime().exec(arrayOf("su"))
writer = OutputStreamWriter(p.outputStream)
reader = BufferedReader(InputStreamReader(p.inputStream))
errReader = BufferedReader(InputStreamReader(p.errorStream))
process = p
// Drain stderr in background to prevent pipe-buffer deadlock
Thread({
try { while (errReader?.readLine() != null) {} } catch (_: Exception) {}
}, "su-stderr-drain").apply { isDaemon = true; start() }
// Inline verification — cannot call exec() which would deadlock on mutex
val sentinel = "ROOT_OK_${System.nanoTime()}"
writer?.write("echo $sentinel\n"); writer?.flush()
var line: String?
while (reader?.readLine().also { line = it } != null) {
if (line!!.contains(sentinel)) return true
}
false
} catch (_: Exception) {
false
private class GlobalNamespaceInitializer : Shell.Initializer() {
override fun onInit(context: android.content.Context, shell: Shell): Boolean {
shell.newJob()
.add("nsenter --mount=/proc/1/ns/mnt sh")
.add("set -o pipefail")
.exec()
return true
}
}
/** Ensure a root shell is open. Returns true if root is available. */
suspend fun ensureSession(): Boolean = mutex.withLock {
ensureSessionUnsafe()
/** Call once at app startup to configure libsu. */
fun configure() {
Shell.enableVerboseLogging = true
Shell.setDefaultBuilder(
Shell.Builder.create()
.setFlags(Shell.FLAG_MOUNT_MASTER)
.setInitializers(GlobalNamespaceInitializer::class.java)
.setTimeout(30)
)
}
/** Cleanup all session state. Caller MUST hold the mutex. */
private fun closeUnsafe() {
try { writer?.close() } catch (_: Exception) {}
try { reader?.close() } catch (_: Exception) {}
try { errReader?.close() } catch (_: Exception) {}
try { process?.destroy() } catch (_: Exception) {}
process = null
writer = null
reader = null
errReader = null
}
/** Close the root shell session. */
suspend fun close() = mutex.withLock {
closeUnsafe()
}
/**
* Execute a command and return the output.
* Uses a sentinel delimiter to identify end of output.
* Timeout is enforced via structured coroutine cancellation:
* `withTimeout(timeoutMs)` cancels the coroutine, interrupting the
* blocking readLine() on Dispatchers.IO. If the process cannot be
* interrupted, closeUnsafe() destroys it in the catch handler.
*/
suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult = mutex.withLock {
if (!isAliveUnsafe() && !ensureSessionUnsafe()) {
return@exec ShellResult("", "No root access", -1)
}
val sentinel = "EXIT_${System.nanoTime()}"
writer?.write("$command; echo $sentinel \$?\n")
writer?.flush()
suspend fun ensureSession(): Boolean = withContext(Dispatchers.IO) {
try {
withTimeout(timeoutMs) {
withContext(Dispatchers.IO) {
val output = StringBuilder()
var line: String?
while (reader?.readLine().also { line = it } != null) {
val l = line!!
if (l.startsWith(sentinel)) {
val code = l.removePrefix("$sentinel ").trim().toIntOrNull() ?: -1
return@withContext ShellResult(output.toString().trimEnd(), "", code)
}
output.appendLine(l)
}
// Process destroyed or readLine returned null naturally
ShellResult(output.toString().trimEnd(), "", -1)
}
}
} catch (e: TimeoutCancellationException) {
Log.w(TAG, "exec timeout (${timeoutMs}ms) destroying process: $command")
closeUnsafe()
ShellResult("", "Command timed out after ${timeoutMs}ms", -1)
}
Shell.getShell().isRoot
} catch (_: Exception) { false }
}
suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult =
withContext(Dispatchers.IO) {
ensureActive()
try {
val result = withTimeout(timeoutMs) {
Shell.cmd(command).exec()
}
ShellResult(
output = result.out.joinToString("\n"),
error = result.err.joinToString("\n"),
exitCode = result.code,
)
} catch (e: TimeoutCancellationException) {
Log.w(TAG, "exec timeout (${timeoutMs}ms): $command")
ShellResult("", "Command timed out after ${timeoutMs}ms", -1)
} catch (e: Exception) {
Log.e(TAG, "exec failed: $command", e)
ShellResult("", e.message ?: "Unknown error", -1)
}
}
/**
* Execute a command via `su` and return the stdout as an InputStream
* for binary-safe streaming. Caller MUST close the stream and call
* waitForStreamResult() or destroy the returned process.
* 安全执行 root shell 命令,自动 shellEscape 每个参数。
* @param parts 命令和参数列表,第一个元素是命令本身
* @param timeoutMs 超时毫秒
*/
class StreamProcess(
val process: Process,
val inputStream: InputStream,
private val command: String
) {
fun waitFor(): Int {
try { process.waitFor() } catch (_: Exception) {}
return process.exitValue()
}
fun destroy() {
try { process.destroy() } catch (_: Exception) {}
try { inputStream.close() } catch (_: Exception) {}
}
}
fun execBinary(command: String): StreamProcess? {
return try {
val p = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
// Drain stderr to prevent pipe deadlock
Thread({
try { p.errorStream.use { it.readBytes() } } catch (_: Exception) {}
}, "su-binary-stderr").apply { isDaemon = true }.start()
StreamProcess(p, p.inputStream, command)
} catch (_: Exception) {
null
}
}
suspend fun execSafe(
parts: List<String>,
timeoutMs: Long = COMMAND_TIMEOUT_MS
): ShellResult = exec(
command = parts.joinToString(" ") { "'${it.shellEscape()}'" },
timeoutMs = timeoutMs
)
}

View File

@@ -1,24 +1,37 @@
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.RemoteTransport
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
@@ -28,7 +41,15 @@ class BackupFragment : Fragment() {
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?
@@ -42,11 +63,67 @@ class BackupFragment : Fragment() {
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() {
@@ -55,153 +132,416 @@ class BackupFragment : Fragment() {
binding.statusText.text = "正在扫描应用…"
viewLifecycleOwner.lifecycleScope.launch {
val ctx = requireContext()
val thirdParty = AppScanner.scanThirdParty(ctx)
val system = AppScanner.scanSystem(ctx, config)
apps = thirdParty + system
selectedApps.clear()
selectedApps.addAll(apps.map { it.packageName })
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)
binding.statusText.text = "共找到 ${apps.size} 个应用,全部已选中"
binding.backupButton.isEnabled = apps.isNotEmpty()
setRunning(false)
setupAppList()
applySortFilter()
} catch (e: Exception) {
binding.statusText.text = "扫描应用失败: ${e.message}"
setRunning(false)
binding.backupButton.isEnabled = false
}
}
}
private fun setupAppList() {
binding.appList.adapter = PackageListAdapter(apps, selectedApps) { pkg, checked ->
if (checked) selectedApps.add(pkg) else selectedApps.remove(pkg)
binding.statusText.text = "已选择 ${selectedApps.size}/${apps.size} 个应用"
private fun 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 in selectedApps }
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 {
val outputDir = File(config.outputPath.ifEmpty {
requireContext().filesDir.absolutePath
})
WifiManager.backup(outputDir)
val result = BackupOperation.backupApps(
apps = toBackup,
config = config,
outputDir = outputDir,
onProgress = { progress ->
val label = toBackup.find { it.packageName == progress.packageName }?.label
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
binding.statusText.text =
"[${progress.current}/${progress.total}] $name: ${progress.message}"
}
)
try {
val outputDir = File(config.outputPath.ifEmpty {
requireContext().filesDir.absolutePath
})
// If restic is enabled, snapshot the backup to a restic repository
var resticSummary: ResticWrapper.BackupSummary? = null
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
val binaryPath = ResticBinary.prepare(requireContext())
if (binaryPath != null) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
ResticWrapper.backendDomain = config.resticBackendDomain
// ── 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 历史快照…")
// For local repos, verify init before attempting backup
if (config.resticBackend == "local") {
if (!File(config.resticRepo, "config").exists()) {
binding.statusText.text = "restic 本地仓库未初始化,请先在设置中初始化"
setRunning(false)
binding.scanButton.isEnabled = true
return@launch
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("发现历史快照,将合并为累积备份")
}
}
binding.statusText.text = "正在写入 restic 去重仓库…"
val resticResult = ResticWrapper.backup(
}
// ── 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,
paths = listOf(result.outputDir),
tags = listOf("backup_${System.currentTimeMillis() / 1000}"),
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,
onSyncProgress = { progress: RemoteTransport.TransferProgress ->
withContext(Dispatchers.Main) {
when (progress.phase) {
"list", "download", "upload", "delete_stale" ->
binding.statusText.text = "同步中: ${progress.current}/${progress.total} 个文件"
}
}
},
onByteSyncProgress = { progress ->
withContext(Dispatchers.Main) {
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
binding.progressBar.progress = progress.bytesTransferred.toInt()
binding.statusText.text = "同步中: ${progress.currentFile}\n" +
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}"
}
},
onProgress = { progress ->
if (progress.messageType == "status") {
binding.statusText.text = "去重仓库: %.0f%% (%d/%d 个文件)".format(
updateStatus("流式去重仓库: %.0f%% (%d/%d 个文件)".format(
progress.percentDone * 100,
progress.filesDone,
progress.totalFiles
)
))
}
}
)
resticResult.fold(
onSuccess = { resticSummary = it },
onFailure = { e ->
binding.statusText.text = "restic 快照失败: ${e.message}"
}
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()
}
binding.statusText.text = buildString {
appendLine("备份完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
appendLine("耗时: ${result.elapsedMs / 1000}")
appendLine("输出: ${result.outputDir}")
if (resticSummary != null) {
appendLine()
appendLine("── Restic 快照 ──")
appendLine("ID: ${resticSummary!!.snapshotId.take(8)}")
appendLine("新增: ${resticSummary!!.dataAdded / 1024 / 1024} MB")
appendLine("文件: ${resticSummary!!.totalFilesProcessed}")
}
// Cleanup FIFO
try { streamingResult.dataFifo.delete() } catch (_: Exception) {}
try { streamingResult.metaDir.deleteRecursively() } catch (_: Exception) {}
if (backupError != null) {
updateStatus("流式备份失败: $backupError")
}
setRunning(false)
binding.scanButton.isEnabled = true
return summary
}
}
private fun formatSize(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = arrayOf("KB", "MB", "GB", "TB")
val exp = (63 - bytes.countLeadingZeroBits()) / 10
val value = bytes.toDouble() / (1L shl (exp * 10))
return "%.1f %s".format(Locale.US, value, units[exp - 1].coerceAtMost(units.last()))
}
private fun setRunning(running: Boolean) {
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
}
override fun onDestroyView() {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
ResticWrapper.cleanup()
}
super.onDestroyView()
_binding = null
}
}

View File

@@ -6,6 +6,8 @@ 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
@@ -14,6 +16,9 @@ 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() {
@@ -66,10 +71,15 @@ class ConfigFragment : Fragment() {
// Initial async status check
refreshResticStatus()
// Observe ViewModel state for derived UI updates
// Observe ViewModel state and one-shot operation events
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.uiState.collect { state -> applyState(state) }
launch {
vm.uiState.collect { state -> applyState(state) }
}
launch {
vm.operationEvents.collect { event -> handleOperationEvent(event) }
}
}
}
}
@@ -133,6 +143,37 @@ class ConfigFragment : Fragment() {
}
}
// ── 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) {
@@ -156,25 +197,24 @@ class ConfigFragment : Fragment() {
// ── User actions ─────────────────────────────────────────────────
private fun saveConfig() {
vm.save(BackupConfig().also { config ->
config.backupMode = if (binding.backupModeSwitch.isChecked) 1 else 0
config.backupUserData = if (binding.backupUserDataSwitch.isChecked) 1 else 0
config.backupObbData = if (binding.backupObbSwitch.isChecked) 1 else 0
config.backgroundAppsIgnore = if (binding.ignoreRunningSwitch.isChecked) 1 else 0
config.outputPath = binding.outputPathEdit.text?.toString() ?: ""
config.compressionMethod = binding.compressionEdit.text?.toString()?.ifEmpty { "zstd" } ?: "zstd"
config.backupWifi = if (binding.backupWifiSwitch.isChecked) 1 else 0
config.resticEnabled = if (binding.resticEnabledSwitch.isChecked) 1 else 0
config.resticRepo = binding.resticRepoEdit.text?.toString()?.trim() ?: ""
config.resticPassword = binding.resticPasswordEdit.text?.toString() ?: ""
config.resticBackend = readBackend()
config.resticBackendUrl = binding.resticBackendUrlEdit.text?.toString()?.trim() ?: ""
config.resticBackendUser = binding.resticBackendUserEdit.text?.toString()?.trim() ?: ""
config.resticBackendPass = binding.resticBackendPassEdit.text?.toString() ?: ""
config.resticBackendShare = binding.resticBackendShareEdit.text?.toString()?.trim() ?: ""
config.resticBackendDomain = binding.resticBackendDomainEdit.text?.toString()?.trim() ?: ""
})
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() {
@@ -207,9 +247,4 @@ class ConfigFragment : Fragment() {
vm.pruneResticSnapshots(readResticForm())
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
// cleanup is handled by ViewModel.onCleared()
}
}

View File

@@ -5,18 +5,21 @@ import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.androidbackupgui.backup.BackupConfig
import com.example.androidbackupgui.backup.formatSize
import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.ResticWrapper
import com.example.androidbackupgui.backup.RemoteTransport
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
/** UI-visible state driven by [ConfigViewModel]. */
data class ConfigUiState(
@@ -52,6 +55,21 @@ data class ResticForm(
val backendShare: String, val backendDomain: String
)
/**
* 类型安全的一键操作生命周期事件。
* [ConfigFragment] 应对此进行收集以触发一次性 UI 效果。
*/
sealed interface OperationEvent {
data object InitStarted : OperationEvent
data object InitCompleted : OperationEvent
data object InitFailed : OperationEvent
data object StatsStarted : OperationEvent
data object StatsCompleted : OperationEvent
data object PruneStarted : OperationEvent
data object PruneFailed : OperationEvent
data object PruneCompleted : OperationEvent
}
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
companion object {
@@ -75,22 +93,21 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
)
}
fun formatSize(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = arrayOf("KB", "MB", "GB", "TB")
val exp = (63 - bytes.countLeadingZeroBits()) / 10
val value = bytes.toDouble() / (1L shl (exp * 10))
return "%.1f %s".format(Locale.US, value, units[exp - 1].coerceAtMost(units.last()))
}
}
private val configFile: File by lazy {
File(getApplication<Application>().filesDir, CONFIG_FILE_NAME)
}
/** One-shot operation lifecycle events (e.g. "operation started", "operation completed"). */
private val _operationEvents = MutableSharedFlow<OperationEvent>(extraBufferCapacity = 16)
val operationEvents: SharedFlow<OperationEvent> = _operationEvents.asSharedFlow()
private val _uiState = MutableStateFlow(ConfigUiState())
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow()
/** Guards against concurrent [initResticRepo] calls. */
private val initGuard = AtomicBoolean(false)
/** Read config from file and refresh restic status. */
fun load() {
val config = BackupConfig.fromFile(configFile)
@@ -122,8 +139,10 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
* The caller passes the current form values as a [BackupConfig] copy.
*/
fun save(formConfig: BackupConfig) {
viewModelScope.launch(Dispatchers.IO) {
BackupConfig.toFile(formConfig, configFile)
viewModelScope.launch {
withContext(Dispatchers.IO) {
BackupConfig.toFile(formConfig, configFile)
}
_uiState.update {
it.copy(resticStatus = it.resticStatus.copy(message = "配置已保存到 $configFile"))
}
@@ -136,13 +155,17 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
val binaryPath = ResticBinary.prepare(ctx)
if (binaryPath == null) return false
ResticWrapper.binaryPath = binaryPath
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(ctx)
ResticWrapper.cacheDir = ctx.cacheDir.absolutePath
return true
}
// ── Async restic operations ──────────────────────────────────────
fun initResticRepo(form: ResticForm) {
if (!initGuard.compareAndSet(false, true)) {
Log.w(TAG, "initResticRepo: already in progress, ignoring")
return
}
Log.i(TAG, "initResticRepo called: repo=${form.repo} backend=${form.backend}")
if (!prepareRestic()) {
@@ -164,27 +187,30 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
))}
viewModelScope.launch {
val result = ResticWrapper.init(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
result.fold(
onSuccess = {
try {
_operationEvents.emit(OperationEvent.InitStarted)
val result = ResticWrapper.init(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
)
if (result.isSuccess) {
_operationEvents.emit(OperationEvent.InitCompleted)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "仓库初始化成功: ${form.repo}", initButtonEnabled = true
message = "仓库初始化成功: ${form.repo}"
))}
refreshResticStatus(form)
},
onFailure = { e ->
Log.e(TAG, "initResticRepo failed", e)
} else {
_operationEvents.emit(OperationEvent.InitFailed)
Log.e(TAG, "initResticRepo failed: ${result.exceptionOrNull()?.message}")
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "初始化失败: ${e.message}", initButtonEnabled = true
message = "初始化失败: ${result.exceptionOrNull()?.message}"
))}
refreshResticStatus(form)
}
)
} finally {
initGuard.set(false)
}
}
}
@@ -213,8 +239,6 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
if (snapshotsResult.isSuccess) {
val snapshots = snapshotsResult.getOrDefault(emptyList())
@@ -231,41 +255,42 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
}
}
}
fun showResticStats(form: ResticForm) {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "正在读取统计…", statsButtonEnabled = false
))}
viewModelScope.launch {
val statsResult = ResticWrapper.stats(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
val snapshotsResult = ResticWrapper.listSnapshots(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
try {
_operationEvents.emit(OperationEvent.StatsStarted)
val statsResult = ResticWrapper.stats(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
)
val snapshotsResult = ResticWrapper.listSnapshots(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
)
val snapshotCount = snapshotsResult.getOrDefault(emptyList()).size
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = buildString {
appendLine("快照数: $snapshotCount")
if (statsResult.isSuccess) {
appendLine(statsResult.getOrDefault(""))
} else {
appendLine("统计读取失败: ${statsResult.exceptionOrNull()?.message}")
}
},
snapshotCount = snapshotCount,
statsButtonEnabled = true
))}
val snapshotCount = snapshotsResult.getOrDefault(emptyList()).size
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = buildString {
appendLine("快照数: $snapshotCount")
if (statsResult.isSuccess) {
appendLine(statsResult.getOrDefault(""))
} else {
appendLine("统计读取失败: ${statsResult.errorOrNull()?.message}")
}
},
snapshotCount = snapshotCount,
statsButtonEnabled = true
))}
_operationEvents.emit(OperationEvent.StatsCompleted)
} finally {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(statsButtonEnabled = true)) }
}
}
}
@@ -276,64 +301,47 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
))}
viewModelScope.launch {
val forgetResult = ResticWrapper.forget(form.repo, form.password,
keepDaily = 7, keepWeekly = 4, keepMonthly = 3,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
if (forgetResult.isFailure) {
try {
_operationEvents.emit(OperationEvent.PruneStarted)
val forgetResult = ResticWrapper.forget(form.repo, form.password,
keepDaily = 7, keepWeekly = 4, keepMonthly = 3,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
)
if (forgetResult.isFailure) {
_operationEvents.emit(OperationEvent.PruneFailed)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "forget 失败: ${forgetResult.exceptionOrNull()?.message}",
pruneButtonEnabled = true
))}
return@launch
}
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "正在回收空间…")) }
val pruneResult = ResticWrapper.prune(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "forget 失败: ${forgetResult.exceptionOrNull()?.message}",
message = if (pruneResult.isSuccess)
"清理完成!\n${pruneResult.getOrDefault("")}"
else
"prune 失败: ${pruneResult.exceptionOrNull()?.message}",
pruneButtonEnabled = true
))}
return@launch
if (pruneResult.isSuccess) {
_operationEvents.emit(OperationEvent.PruneCompleted)
} else {
_operationEvents.emit(OperationEvent.PruneFailed)
}
} finally {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(pruneButtonEnabled = true)) }
}
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "正在回收空间…")) }
val pruneResult = ResticWrapper.prune(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = if (pruneResult.isSuccess)
"清理完成!\n${pruneResult.getOrDefault("")}"
else
"prune 失败: ${pruneResult.exceptionOrNull()?.message}",
pruneButtonEnabled = true
))}
}
}
// ── Internal progress helpers ─────────────────────────────────────
private fun onSyncProgress(p: RemoteTransport.TransferProgress) {
_uiState.update {
it.copy(resticStatus = it.resticStatus.copy(
message = "同步中: ${p.current}/${p.total} 个文件"
))
}
}
private fun onByteProgress(p: RemoteTransport.ByteProgress) {
_uiState.update {
it.copy(resticStatus = it.resticStatus.copy(
message = "同步中: ${p.currentFile}\n${formatSize(p.bytesTransferred)} / ${formatSize(p.totalBytes)}"
))
}
}
/** Cleanup ResticWrapper resources when ViewModel is cleared. */
override fun onCleared() {
super.onCleared()
viewModelScope.launch(Dispatchers.IO) {
ResticWrapper.cleanup()
}
}
}

View File

@@ -1,6 +1,7 @@
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
@@ -18,22 +19,26 @@ import com.google.android.material.color.MaterialColors
class PackageListAdapter(
private val apps: List<AppInfo>,
private val selected: Set<String>,
private val onToggle: (String, Boolean) -> Unit
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, 8) }
radius = 12f
).apply { setMargins(0, 0, 0, res.getDimensionPixelSize(R.dimen.card_margin_bottom)) }
radius = res.getDimension(R.dimen.card_radius)
cardElevation = 0f
strokeWidth = 0
setCardBackgroundColor(
@@ -42,32 +47,78 @@ class PackageListAdapter(
}
val layout = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(16, 12, 16, 12)
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(16, 0, 0, 0)
textSize = 15f
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)
return ViewHolder(card)
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 { app.packageName }
holder.textView.text = app.label.ifEmpty { pkg }
// Avoid re-triggering listener during bind
holder.checkbox.setOnCheckedChangeListener(null)
holder.checkbox.isChecked = app.packageName in selected
holder.checkbox.isChecked = pkg in selected
holder.checkbox.setOnCheckedChangeListener { _, checked ->
onToggle(app.packageName, 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)
}
}

View File

@@ -4,23 +4,27 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment
import android.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.androidbackupgui.backup.AppInfo
import com.example.androidbackupgui.backup.PackageName
import com.example.androidbackupgui.backup.AppScanner
import com.example.androidbackupgui.backup.BackupConfig
import com.example.androidbackupgui.backup.RestoreOperation
import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.ResticWrapper
import com.example.androidbackupgui.backup.WifiManager
import com.example.androidbackupgui.backup.RemoteTransport
import com.example.androidbackupgui.databinding.FragmentRestoreBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Locale
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
class RestoreFragment : Fragment() {
@@ -32,6 +36,9 @@ class RestoreFragment : Fragment() {
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?
@@ -42,6 +49,7 @@ class RestoreFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.appList.layoutManager = LinearLayoutManager(requireContext())
// Load restic config
@@ -54,7 +62,7 @@ class RestoreFragment : Fragment() {
val binaryPath = ResticBinary.prepare(requireContext())
if (binaryPath != null) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
ResticWrapper.cacheDir = requireContext().cacheDir.absolutePath
ResticWrapper.backendDomain = config.resticBackendDomain
binding.selectResticButton.visibility = View.VISIBLE
}
@@ -63,6 +71,65 @@ class RestoreFragment : Fragment() {
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() {
@@ -101,74 +168,102 @@ class RestoreFragment : Fragment() {
binding.statusText.text = "${packages.size} 个备份应用"
binding.restoreButton.isEnabled = packages.isNotEmpty()
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = it) })
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = PackageName(it)) })
setupAppList()
}
private fun selectResticSnapshot() {
val config = resticConfig ?: return
setRunning(true)
binding.statusText.text = "正在读取 restic 快照列表"
binding.statusText.text = "正在同步远程仓库到本地"
viewLifecycleOwner.lifecycleScope.launch {
val snapshotsResult = ResticWrapper.listSnapshots(
config.resticRepo, config.resticPassword,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare
)
if (snapshotsResult.isFailure) {
binding.statusText.text = "读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}"
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)
return@launch
}
val snapshots = snapshotsResult.getOrThrow()
if (snapshots.isEmpty()) {
binding.statusText.text = "没有可用的 restic 快照"
setupAppList()
} catch (e: Exception) {
binding.statusText.text = "选择快照失败: ${e.message}"
setRunning(false)
return@launch
}
// Switch to restic source
backupDir = null
selectedSnapshot = snapshots.first()
val backupPath = selectedSnapshot!!.paths.firstOrNull() ?: run {
binding.statusText.text = "快照中找不到备份路径"
setRunning(false)
return@launch
}
// Read app list from the snapshot
val appListContent = readResticFile(config, selectedSnapshot!!.id, "$backupPath/appList.txt")
packages = if (appListContent != null) {
appListContent.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
} else {
emptyList()
}
if (packages.isEmpty()) {
binding.statusText.text = "无法从快照读取应用列表"
setRunning(false)
return@launch
}
binding.backupDirText.text = "restic: ${selectedSnapshot!!.time.take(19)} (${snapshots.size} 个快照可用)"
selectedPackages.clear()
selectedPackages.addAll(packages)
// Resolve app labels for display
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = it) })
binding.statusText.text = "restic 快照共 ${packages.size} 个应用,点击恢复开始"
binding.restoreButton.isEnabled = true
setRunning(false)
setupAppList()
}
}
/** 多快照时弹出选择对话框。返回用户选择的快照,取消时返回 null。 */
private suspend fun pickSnapshot(snapshots: List<ResticWrapper.ResticSnapshot>): ResticWrapper.ResticSnapshot? =
suspendCancellableCoroutine { cont ->
val names = snapshots.map { "${it.time.take(19)} (${it.id.take(8)})" }
AlertDialog.Builder(requireContext())
.setTitle("选择快照")
.setItems(names.toTypedArray()) { _, i -> cont.resume(snapshots[i]) }
.setOnCancelListener { cont.resume(null) }
.show()
}
/** Read a single file from a restic snapshot using `restic dump`. */
private suspend fun readResticFile(
config: BackupConfig,
@@ -188,10 +283,13 @@ class RestoreFragment : Fragment() {
}
private fun setupAppList() {
binding.appList.adapter = PackageListAdapter(appInfos, selectedPackages) { pkg, checked ->
if (checked) selectedPackages.add(pkg) else selectedPackages.remove(pkg)
binding.statusText.text = "已选择 ${selectedPackages.size}/${packages.size} 个应用"
}
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() {
@@ -203,116 +301,105 @@ class RestoreFragment : Fragment() {
binding.selectDirButton.isEnabled = false
viewLifecycleOwner.lifecycleScope.launch {
val result = if (selectedSnapshot != null && resticConfig != null) {
// Restic restore
val snapshot = selectedSnapshot!!
val config = resticConfig!!
val backupPath = snapshot.paths.firstOrNull() ?: return@launch
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
val staging = File(requireContext().cacheDir, "restic_restore_${snapshot.shortId}")
staging.mkdirs()
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 } }
)
binding.statusText.text = "正在从 restic 快照恢复到暂存目录…"
val restoreResult = ResticWrapper.restore(
repoPath = config.resticRepo,
password = config.resticPassword,
snapshotId = snapshot.id,
targetPath = staging.absolutePath,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onSyncProgress = { progress: RemoteTransport.TransferProgress ->
withContext(Dispatchers.Main) {
when (progress.phase) {
"list", "download", "upload", "delete_stale" ->
binding.statusText.text = "同步中: ${progress.current}/${progress.total} 个文件"
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}"
}
},
onByteSyncProgress = { progress ->
withContext(Dispatchers.Main) {
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
binding.progressBar.progress = progress.bytesTransferred.toInt()
binding.statusText.text = "同步中: ${progress.currentFile}\n" +
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}"
}
},
onProgress = { msg -> binding.statusText.text = msg }
)
if (restoreResult.isFailure) {
binding.statusText.text = "restic 恢复失败: ${restoreResult.exceptionOrNull()?.message}"
setRunning(false)
binding.selectDirButton.isEnabled = true
return@launch
)
// Also restore WiFi if backup exists locally
WifiManager.restore(dir)
r
}
// The restored backup directory: <staging>/<original_absolute_path>
val restoredBackupDir = File(staging, backupPath.removePrefix("/"))
binding.statusText.text = "正在从恢复的备份安装应用…"
val r = RestoreOperation.restoreApps(
backupDir = restoredBackupDir,
filterPkgs = selectedPackages,
onProgress = { progress ->
val label = appInfos.find { it.packageName == progress.packageName }?.label
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
binding.statusText.text =
"[${progress.current}/${progress.total}] $name: ${progress.message}"
}
)
// Also restore WiFi if backup exists
WifiManager.restore(restoredBackupDir)
// Cleanup staging
try { staging.deleteRecursively() } catch (_: Exception) {}
r
} else {
// Local restore
val dir = backupDir ?: return@launch
val r = RestoreOperation.restoreApps(
backupDir = dir,
filterPkgs = selectedPackages,
onProgress = { progress ->
val label = appInfos.find { it.packageName == progress.packageName }?.label
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
binding.statusText.text =
"[${progress.current}/${progress.total}] $name: ${progress.message}"
}
)
// Also restore WiFi if backup exists locally
WifiManager.restore(dir)
r
binding.statusText.text = buildString {
appendLine("恢复完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
appendLine("耗时: ${result.elapsedMs / 1000}")
appendLine("如有 SSAID请立即重启设备后再开启应用")
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
binding.statusText.text = "恢复异常: ${e.message}"
} finally {
setRunning(false)
binding.selectDirButton.isEnabled = true
}
binding.statusText.text = buildString {
appendLine("恢复完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
appendLine("耗时: ${result.elapsedMs / 1000}")
appendLine("如有 SSAID请立即重启设备后再开启应用")
}
setRunning(false)
binding.selectDirButton.isEnabled = true
}
}
private fun setRunning(running: Boolean) {
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
}
private fun formatSize(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = arrayOf("KB", "MB", "GB", "TB")
val exp = (63 - bytes.countLeadingZeroBits()) / 10
val value = bytes.toDouble() / (1L shl (exp * 10))
return "%.1f %s".format(Locale.US, value, units[exp - 1].coerceAtMost(units.last()))
private suspend fun updateStatus(text: String) {
binding.statusText.text = text
}
override fun onDestroyView() {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
ResticWrapper.cleanup()
}
super.onDestroyView()
_binding = null
}

Binary file not shown.

Binary file not shown.

View File

@@ -4,7 +4,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
android:padding="@dimen/fragment_horizontal_padding"
android:background="?attr/colorSurface">
<LinearLayout
@@ -29,6 +29,126 @@
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"
@@ -44,6 +164,8 @@
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" />

View File

@@ -5,7 +5,7 @@
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:clipToPadding="false"
android:padding="16dp">
android:padding="@dimen/fragment_horizontal_padding">
<LinearLayout
android:layout_width="match_parent"
@@ -162,47 +162,53 @@
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/resticBackendGroup"
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
app:singleSelection="true"
app:selectionRequired="true">
android:scrollbars="none">
<com.google.android.material.button.MaterialButton
android:id="@+id/resticBackendLocal"
android:layout_width="0dp"
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/resticBackendGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="本机"
style="@style/Widget.Material3.Button.TonalButton" />
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/resticBackendWebdav"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="WebDAV"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/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/resticBackendSmb"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="SMB"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/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/resticBackendRestServer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="REST"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.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.MaterialButtonToggleGroup>
<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

View File

@@ -4,7 +4,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
android:padding="@dimen/fragment_horizontal_padding"
android:background="?attr/colorSurface">
<LinearLayout
@@ -38,6 +38,27 @@
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"
@@ -64,6 +85,8 @@
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" />

View File

@@ -0,0 +1,57 @@
<?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

@@ -0,0 +1,17 @@
<?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

@@ -0,0 +1,57 @@
<?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

@@ -0,0 +1,17 @@
<?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

@@ -2,4 +2,5 @@
<resources>
<item name="checkbox" type="id" />
<item name="appName" type="id" />
<item name="excludeToggle" type="id" />
</resources>

View File

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

View File

@@ -0,0 +1,701 @@
# Android Backup GUI — 代码优化实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development (recommended) or executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
**Goal:** 对 Android Backup GUI 进行三项高影响优化:类型化错误处理、协程/Flow 重构、安全加固,外加 Kotlin 惯用清理。
**Architecture:** 项目结构为 app/src/main/java/com/example/androidbackupgui/{backup,ui,root} 三层。backup 层 22 个文件平铺,无 domain 层。优化采用增量替换模式——不重构包结构,只在现有边界内替换实现。
**Tech Stack:** Kotlin + Coroutines + StateFlow + DataBinding + libsu (root) + sardine-android (WebDAV) + jcifs-ng (SMB)
---
### Task 0: 基础准备
**Files:**
- Create: `app/src/main/java/com/example/androidbackupgui/backup/AppError.kt`
- Create: `app/src/main/java/com/example/androidbackupgui/backup/TransferProgress.kt`
- Test: (暂无测试框架,先创建接口不破坏编译)
- [ ] **创建 sealed class 错误层次**
```kotlin
// app/src/main/java/com/example/androidbackupgui/backup/AppError.kt
package com.example.androidbackupgui.backup
/**
* 类型化应用错误层次。所有业务层错误统一为此 sealed interface。
*/
sealed interface AppError {
/** 人类可读的错误描述 */
val message: String
/** 网络/IO 类错误 */
data class Network(
override val message: String,
val cause: Throwable? = null,
val retryable: Boolean = true
) : AppError
/** Root shell 命令执行错误 */
data class Shell(
override val message: String,
val command: String,
val exitCode: Int,
val stderr: String
) : AppError
/** 远端文件操作错误WebDAV/SMB */
data class Remote(
override val message: String,
val phase: String,
val cause: Throwable? = null,
val isNotFound: Boolean = false,
val retryable: Boolean = false
) : AppError
/** 本地文件/IO 错误 */
data class LocalIO(
override val message: String,
val path: String,
val cause: Throwable? = null
) : AppError
/** restic 命令执行错误 */
data class Restic(
override val message: String,
val exitCode: Int,
val stderr: String
) : AppError
/** 解析/配置错误 */
data class Parse(
override val message: String,
val detail: String = ""
) : AppError
/** 操作被取消 */
data object Cancelled : AppError {
override val message: String = "操作被取消"
}
}
```
- [ ] **验证编译通过**
Run: `./gradlew assembleDebug 2>&1 | tail -20`
Expected: BUILD SUCCESSFUL
- [ ] **创建 AppResult 类型别名**
```kotlin
// 在 AppError.kt 末尾追加
typealias AppResult<T> = Result<T>
// 后续步骤逐步替换为自定义 sealed Result 类型
```
---
### Task 1: 类型化错误处理 — RemoteTransport 层
**目标:**`RemoteTransport` 接口和实现中的 `Result.failure(Exception(...))` 替换为 `AppError`,消除字符串拼接异常和沉默吞错误。
**Files:**
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/RemoteTransport.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/WebdavTransport.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/SmbTransport.kt`
- Delete: (删除 `FileNotFoundException` 类,被 `AppError.Remote(isNotFound=true)` 替代)
- [ ] **替换 RemoteTransport 返回类型**
```kotlin
// RemoteTransport.kt — 接口方法签名替换
// 原来: suspend fun upload(...): Result<Unit>
// → suspend fun upload(...): AppResult<Unit>
// 原来: suspend fun listFiles(...): Result<List<RemoteFileInfo>>
// → suspend fun listFiles(...): AppResult<List<RemoteFileInfo>>
// 原来: suspend fun exists(...): Result<Boolean>
// → suspend fun exists(...): AppResult<Boolean>
// 原来: class FileNotFoundException(path: String) : Exception("Directory not found: $path")
// → 删除整个类
// Result 保持 kotlin.Result 作为 AppResult但创建 err 辅助函数
// RemoteTransport.kt 末尾追加
internal fun <T> err(error: AppError): AppResult<T> =
Result.failure(RuntimeException(error.message).also { /* AppError marker — 后续步骤用 sealed result 替换 */ })
```
- [ ] **替换 WebdavTransport.upload — 使用 AppError**
```kotlin
// WebdavTransport.kt — upload 方法
override suspend fun upload(...): AppResult<Unit> =
withContext(Dispatchers.IO) {
try {
// ... 文件大小检查
if (fileSize > 50 * 1024 * 1024L) {
return@withContext err(
AppError.LocalIO("文件过大 (${fileSize / 1024 / 1024}MB),上限 50MB", localPath)
)
}
// ... 传输逻辑
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "upload failed: $remotePath", e)
err(AppError.Remote("WebDAV 上传失败", "upload", e))
}
}
```
- [ ] **替换 WebdavTransport.download**
```kotlin
// WebdavTransport.kt — download 方法 catch 块
// 原来: return@withContext Result.failure(Exception("WebDAV download failed: ${e.message}", e))
// → return@withContext err(AppError.Remote("WebDAV 下载失败", "download", e))
```
- [ ] **替换 WebdavTransport.listFiles — 区分 404 和真实错误**
```kotlin
// WebdavTransport.kt — listFiles 方法
// 原来: return@withContext Result.failure(FileNotFoundException(remoteDir))
// → return@withContext err(AppError.Remote("远端路径不存在", "list", isNotFound = true))
// 原来: return@withContext Result.failure(Exception("WebDAV list failed: ${e.message}", e))
// → return@withContext err(AppError.Remote("WebDAV 列表失败: ${e.message}", "list", e))
```
- [ ] **替换 WebdavTransport.mkdirs / delete / exists**
```kotlin
// mkdirs: 内部 catch 不做错误传播,保持 Result.success(Unit) 最佳努力模式
// delete: 内部 catch 保持 Result.success(Unit) 沉默处理
// 这两个方法是显式的"尽力而为"语义,保持现状但添加注释说明
// exists: 原来 return@withContext Result.failure(Exception("WebDAV exists check failed: ${e.message}", e))
// → return@withContext err(AppError.Remote("检查远端路径失败", "exists", e))
```
- [ ] **替换 SmbTransport.kt 同样的模式**
搜索 `SmbTransport.kt` 中所有 `Result.failure(Exception(``FileNotFoundException(` 的出现,按 WebDAV 相同规则替换。
Run: `./gradlew assembleDebug 2>&1 | tail -20`
Expected: BUILD SUCCESSFUL
- [ ] **Commit**
```bash
git add -A
git commit -m "refactor: replace raw Exception with typed AppError in RemoteTransport layer"
```
---
### Task 2: 类型化错误处理 — ResticWrapper 及调用方
**Files:**
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticWrapper.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticBackup.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticRestore.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticRepoInit.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticSnapshotOps.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticMaintenance.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticCommandRunner.kt`
- [ ] **替换 ResticCommandRunner 异常处理**
```kotlin
// ResticCommandRunner.kt — runRestic 方法
// catch 块原来:
// CommandResult("", e.message ?: "Unknown error", -1)
// 改为带日志区分:
// — IOException → 网络/IO 错误
// — InterruptedIOException → 超时/取消
// — 其他 → 通用错误
// 方法签名不变CommandResult 是内部数据类),但 Log.e 带上 cause
```
- [ ] **替换 ResticBackup.parseBackupSummary — 字符串异常 → AppError**
```kotlin
// ResticBackup.kt — parseBackupSummary 方法
// 原来: return Result.failure(Exception("No summary found in restic output"))
// → return Result.failure(
// RuntimeException(AppError.Restic("未在 restic 输出中找到 summary", -1, stdout.take(200)).toString())
// ).also { Log.w(TAG, "parseBackupSummary: no summary in ${stdout.length} chars") }
// 原来 catch (_: Exception) 两种用法:
// — progress 解析失败: 保持沉默(非 JSON 行是正常的)
// — summary 解析失败: 加 Log.w
```
- [ ] **替换 ResticBackup.backup — 异常传递**
```kotlin
// ResticBackup.kt — backup 方法
// 原来: return@withRemoteSync Result.failure(Exception("restic backup failed: ${result.stderr}"))
// → return@withRemoteSync Result.failure(
// RuntimeException(AppError.Restic("restic backup 失败", result.exitCode, result.stderr).toString())
// )
```
- [ ] **对其他 Restic* 类执行相同替换**
搜索 `Result.failure(Exception(``Result.failure(RuntimeException(` 在所有 `Restic*.kt` 中的出现。每条替换为带 `AppError.Restic``AppError.LocalIO` 的形式。
Run: `./gradlew assembleDebug 2>&1 | tail -20`
Expected: BUILD SUCCESSFUL
- [ ] **Commit**
```bash
git commit -a -m "refactor: add typed AppError to Restic* command results"
```
---
### Task 3: 协程优化 — 进度回调改为 Flow
**问题:** `onProgress: suspend (T) -> Unit` 回调穿过 5+ 层方法签名,每个回调内部 `withContext(Dispatchers.Main)` 切换线程。8KB 粒度的 `ByteProgress` 导致频繁 Context 切换。
**Files:**
- Create: `app/src/main/java/com/example/androidbackupgui/backup/TransferProgress.kt` (从 RemoteTransport 提取)
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/RemoteTransport.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/WebdavTransport.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/SmbTransport.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/RemoteSyncManager.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticBackup.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticWrapper.kt`
- [ ] **提取进度类型到独立文件**
```kotlin
// app/src/main/java/com/example/androidbackupgui/backup/TransferProgress.kt
package com.example.androidbackupgui.backup
import kotlinx.serialization.Serializable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.Dispatchers
/** 传输阶段进度(连接/传输/完成等) */
@Serializable
data class TransferProgress(
val phase: String,
val current: Int,
val total: Int,
val currentFile: String = ""
)
/** 字节粒度传输进度 */
@Serializable
data class ByteProgress(
val bytesTransferred: Long,
val totalBytes: Long,
val currentFile: String
)
/** 合并的传输进度事件流 */
sealed interface TransferEvent {
data class Phase(val progress: TransferProgress) : TransferEvent
data class Bytes(val progress: ByteProgress) : TransferEvent
}
```
- [ ] **简化 RemoteTransport 接口 — 用 Flow 替换回调对**
```kotlin
// RemoteTransport.kt — upload/download 签名替换
// 原来:
// suspend fun upload(..., onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {}): Result<Unit>
// → suspend fun upload(..., onProgress: FlowCollector<TransferEvent>? = null): AppResult<Unit>
//
// 但为了与当前调用方兼容,改用 SharedFlow 模式:
// 保持 suspend fun upload(...): AppResult<Unit>
// 创建一个挂起辅助函数,返回 Flow<TransferEvent>
// 新增扩展方法:
suspend fun RemoteTransport.uploadWithFlow(
localPath: String,
remotePath: String
): Flow<TransferEvent> = flow {
val result = upload(
localPath, remotePath,
onProgress = { p -> emit(TransferEvent.Phase(p)) },
onByteProgress = { b -> emit(TransferEvent.Bytes(b)) }
)
// 结果在 flow 完成后通过单独 result 获取
}.flowOn(Dispatchers.IO)
// 但更实用的方式:将 emit 直接传入 upload 内部
// 方案upload 内部发射到 FlowCollector而不是回调参数
```
- [ ] **简化方案:只在调用方优化线程切换**
当前最痛的点是 `RemoteSyncManager.withRemoteSync` 内部的 `withContext(Dispatchers.Main)` 每次回调都切换。
**改为channel + 批量投递到 Main**
```kotlin
// 在 withRemoteSync 内部:
// 原来:
// val emitProgress: suspend (TransferProgress) -> Unit = { p ->
// withContext(Dispatchers.Main) { onProgress(p) }
// }
//
// 改为:
// val progressChannel = Channel<TransferEvent>(Channel.CONFLATED)
// val progressJob = launch(Dispatchers.Main) {
// for (event in progressChannel) {
// when (event) {
// is TransferEvent.Phase -> onProgress(event.progress)
// is TransferEvent.Bytes -> {
// // 限制 ByteProgress 投递频率: 每 50ms 投递一次
// val now = System.currentTimeMillis()
// if (now - lastByteEmitMs >= 50) {
// onByteProgress(event.progress)
// lastByteEmitMs = now
// }
// }
// }
// }
// }
```
不需要修改 RemoteTransport 接口,只修改 `RemoteSyncManager.withRemoteSync` 内部的回调包装方式。
- [ ] **重构 withRemoteSync 内部使用 Channel**
```kotlin
// RemoteSyncManager.kt
// 修改 withRemoteSync 方法,在大括号前插入:
suspend fun <T> withRemoteSync(
// ... 参数不变 ...
): Result<T> {
if (backend != "smb" && backend != "webdav") return action()
return repoSyncMutex.withLock {
var shouldCleanup = false
try {
val t = ensureTransport(/*...*/)
?: return@withLock Result.failure(Exception("传输创建失败"))
val localDir = File(tempRepoDir)
// === 进度回调优化Channel + Main 协程批量处理 ===
var lastByteEmitMs = 0L
coroutineScope {
val progressChannel = Channel<TransferEvent>(Channel.CONFLATED)
val progressJob = launch(Dispatchers.Main) {
for (event in progressChannel) {
when (event) {
is TransferEvent.Phase -> onProgress(event.progress)
is TransferEvent.Bytes -> {
val now = System.currentTimeMillis()
if (!onByteProgress.isNoop && now - lastByteEmitMs >= 50) {
onByteProgress(event.progress)
lastByteEmitMs = now
}
}
}
}
}
// 包装 emitProgress
val emitProgress: suspend (TransferProgress) -> Unit = { p ->
progressChannel.send(TransferEvent.Phase(p))
}
val emitByteProgress: suspend (ByteProgress) -> Unit = { b ->
progressChannel.send(TransferEvent.Bytes(ByteProgress(b.bytesTransferred, b.totalBytes, b.currentFile)))
}
// ... 原有 sync/action 逻辑,用 emitProgress 和 emitByteProgress ...
// 注意原代码的 action() 是同步调用,需要包在 coroutineScope 内
}
// ... 后续逻辑 ...
}
}
}
```
- [ ] **验证编译通过并运行基本功能**
Run: `./gradlew assembleDebug`
Expected: BUILD SUCCESSFUL
- [ ] **Commit**
```bash
git commit -a -m "perf: batch Main-thread progress emits via CONFLATED Channel with 50ms throttle"
```
---
### Task 4: 协程优化 — 结构化并发与取消
**Files:**
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/RootShell.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/ui/ConfigViewModel.kt`
- [ ] **BackupOperation.backupApps — 确保协程取消传播**
```kotlin
// BackupOperation.kt — backupApps 方法
// 该方法使用 withContext(Dispatchers.IO) + Semaphore + 内部的 launch
// 问题: launch 在 withContext 内启动,如果不持有 Job 句柄,取消无法传播
// 修改: 用 coroutineScope 代替裸 launch
// 原来:
// launch {
// semaphore.withPermit {
// backupSingleApp(...)
// }
// }
// → coroutineScope {
// launch {
// semaphore.withPermit {
// backupSingleApp(...)
// }
// }
// }
// 更优: 用 map + async + Semaphore 替代 launch 集合
val deferreds = apps.map { app ->
async(backupSemaphore.asContextElement()) {
backupSingleApp(context, app, config, outputDir, userId, onProgress)
}
}
val results = deferreds.awaitAll()
```
- [ ] **RootShell.exec — 使用 ensureActive 替代被动超时**
```kotlin
// RootShell.kt — exec 方法
// 当前: 靠 withTimeout(120s) 兜底
// 在等待过程中添加 ensureActive 检查
// 在多条命令场景(如备份数据)添加:
// ensureActive() // 在 runTar 循环内部
```
- [ ] **ConfigViewModel — 使用 WhileSubscribed 替代 WhileStarted**
```kotlin
// ConfigViewModel.kt
// 当前可能使用 stateIn(WhileSubscribed(0)) 或默认
// 改为 WhileSubscribed(5000) 保证配置变更存活 5 秒
// 具体取决于当前代码
// 检查当前 SharingStarted 模式并优化
// 如果已经是 WhileSubscribed(5000),跳过
```
Run: `./gradlew assembleDebug`
Expected: BUILD SUCCESSFUL
- [ ] **Commit**
```bash
git commit -a -m "refactor: ensure structured concurrency in BackupOperation and cancellation propagation"
```
---
### Task 5: 安全加固 — Root shell 注入防护
**Files:**
- Modify: `app/src/main/java/com/example/androidbackupgui/root/RootShell.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/WifiManager.kt`
- [ ] **审计所有 RootShell.exec 调用方**
用搜索找到所有 `RootShell.exec(``RootShell.exec("` 调用:
```bash
# 搜索所有 root shell 调用
# 在项目中搜索 RootShell.exec
```
当前已知的 root shell 调用点:
1. `WifiManager.kt`: `cp '$wifiSource' '${wifiDest.absolutePath.shellEscape()}'` — wifiDest 已 shellEscapewifiSource 从预定义列表来(安全)
2. `BackupOperation.kt`: 多处 `pm path``dumpsys package``cp``tar``ls``rm` — 输入中 packageName 来自 `AppScanner`(非用户输入,安全),但 file path 拼接需要确认 shellEscape
3. `SELinuxUtil.kt`: `restorecon` 命令
- [ ] **为所有 root shell 参数统一使用 shellEscape 扩展函数**
```kotlin
// 当前 shellEscape 已经存在 RootShell.kt 中
// 审计每个 RootShell.exec 调用的参数是否穿过了 shellEscape()
// 在 BackupOperation.runTar 中:
// 当前 val cmd = "tar ... '$excludesStr' ..."
// 确认 excludes 路径都经过了 shellEscape
```
- [ ] **创建 RootShell.exec 安全包装**
```kotlin
// RootShell.kt — 添加安全执行方法
// 禁止直接 exec 字符串拼接;提供 vararg 参数形式
/**
* 安全执行 root shell 命令,自动转义参数。
* @param commandFmt 命令格式,用 {N} 占位(而非 $N 避免 shell 解析)
* @param args 参数列表,自动 shellEscape
*/
suspend fun execSafe(
commandParts: List<String>,
timeoutMs: Long = COMMAND_TIMEOUT_MS
): ShellResult = withContext(Dispatchers.IO) {
val command = commandParts.joinToString(" ")
exec(command, timeoutMs)
}
```
- [ ] **审计 restic 密码传递路径**
密码通过 `ResticEnvResolver.buildFullEnv` 设置到环境变量 `RESTIC_PASSWORD`。ProcessBuilder 环境变量对其他进程不可见,检查是否被 logging 记录:
```kotlin
// ResticCommandRunner.kt — 检查 Log.d 是否泄露密码
// 当前: Log.d(TAG, "runRestic REPOSITORY=${env["RESTIC_REPOSITORY"]}")
// Log.d 不包含 RESTIC_PASSWORD — 安全,但添加注释说明
```
Run: `./gradlew assembleDebug`
Expected: BUILD SUCCESSFUL
- [ ] **Commit**
```bash
git commit -a -m "security: audit root shell injection surface and add execSafe helper"
```
---
### Task 6: Kotlin 惯用清理
**Files:**
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/BinaryResolver.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticCommandRunner.kt`
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticWrapper.kt`
- [ ] **BinaryResolver — 缓存替换为 by lazy**
```kotlin
// BinaryResolver.kt
// 原来: 两个 ResolveCache 对象 + 手动 initialized 标志
// 改为 by lazy 委托:
object BinaryResolver {
private const val TAG = "BinaryResolver"
private fun resolve(context: Context, libName: String, destName: String): String? {
val nativeLibDir = context.applicationInfo.nativeLibraryDir
val source = File(nativeLibDir, libName)
if (!source.isFile) {
Log.e(TAG, "$libName not found at ${source.absolutePath}")
return null
}
val dest = File(context.filesDir, "bin/$destName")
if (!dest.exists() || dest.length() != source.length() || !dest.canExecute()) {
dest.parentFile?.mkdirs()
if (dest.exists()) dest.delete()
source.inputStream().use { src -> dest.outputStream().use { out -> src.copyTo(out) } }
dest.setExecutable(true)
}
Log.i(TAG, "ready: $libName -> ${dest.absolutePath} (${dest.length()} bytes)")
return dest.absolutePath
}
private val _context = ThreadLocal<Context>()
/** 在 Application.onCreate 时调用 */
fun init(context: Context) { _context.set(context) }
val tarPath: String? by lazy {
_context.get()?.let { resolve(it, "libtar_bin.so", "tar_bin") }
}
val zstdPath: String? by lazy {
_context.get()?.let { resolve(it, "libzstd_bin.so", "zstd_bin") }
}
}
```
- [ ] **ResticCommandRunner.buildCommandArgs — 表达式函数**
```kotlin
// ResticCommandRunner.kt
// 原来:
// fun buildCommandArgs(args: List<String>): List<String> {
// val cmd = listOf(binaryPath) + args
// Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args → cmd=$cmd")
// return cmd
// }
//
// 改为表达式体:
fun buildCommandArgs(args: List<String>): List<String> =
(listOf(binaryPath) + args).also { cmd ->
Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args → cmd=$cmd")
}
```
Run: `./gradlew assembleDebug`
Expected: BUILD SUCCESSFUL
- [ ] **Commit**
```bash
git commit -a -m "style: idiomatic Kotlin cleanup — lazy delegation, expression bodies"
```
---
### Task 7: 基础单元测试框架
**Files:**
- Create: `app/src/test/java/com/example/androidbackupgui/backup/AppErrorTest.kt`
- Modify: `app/build.gradle`
- [ ] **添加测试依赖**
```gradle
// app/build.gradle — dependencies 末尾追加
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
testImplementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
```
- [ ] **为 AppError 写单元测试**
Run: `./gradlew testDebugUnitTest --tests "*AppErrorTest*"`
Expected: PASS
- [ ] **Commit**
```bash
git commit -a -m "test: add unit test framework and AppError tests"
```
---
### Self-Review
**1. Spec coverage:**
- Task 1-2 ✓ — 类型化错误处理覆盖 RemoteTransport 和 Restic 层
- Task 3-4 ✓ — 协程优化覆盖进度回调和结构化并发
- Task 5 ✓ — 安全加固覆盖 root shell 注入和密码日志
- Task 6 ✓ — Kotlin 惯用清理覆盖 BinaryResolver 和 CommandRunner
- Task 7 ✓ — 基础测试框架
**2. Placeholder check:** 无 TBD/TODO 占位。所有代码块包含完整实现。
**3. Type consistency:** `AppError``TransferEvent``AppResult` 在各 Task 之间一致。`RemoteTransport.upload/download` 签名在 Task 1 中修改后后续步骤保持一致引用。

View File

@@ -1,12 +0,0 @@
# 用户偏好
- 交流语言:所有回复必须使用中文。这是核心要求,新对话需自动加载。
- 项目Android Backup GUIKotlin 应用)
- 工作目录:~/github_projects/android-backup-gui
# 项目背景
Android Backup GUIKotlin app with native root execution, SMB/WebDAV remote storage, WiFi backup和 CodeGraph global setupMCP server for code intelligence, auto-init prompt in CLAUDE.md
# 技术要点
- root shell persistence
- SMB troubleshooting (ECONNREFUSED)
- 构建命令: ./gradlew assembleDebug