12 Commits
v1.3 ... v1.11

Author SHA1 Message Date
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
30 changed files with 2064 additions and 896 deletions

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **android-backup-gui** (922 symbols, 2334 relationships, 79 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **android-backup-gui** (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** (922 symbols, 2334 relationships, 79 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **android-backup-gui** (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

@@ -26,8 +26,8 @@ android {
applicationId "com.example.androidbackupgui"
minSdk 24
targetSdk 34
versionCode 4
versionName "1.3"
versionCode 12
versionName "1.11"
}
buildFeatures {
viewBinding true
@@ -37,20 +37,17 @@ android {
}
signingConfigs {
release {
def keystoreFile = file("release.keystore")
if (keystoreFile.exists()) {
storeFile keystoreFile
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "android"
keyAlias "release"
keyPassword System.getenv("KEY_PASSWORD") ?: "android"
}
storeFile rootProject.file("app/release.keystore")
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "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'
if (file("release.keystore").exists()) {
if (rootProject.file("app/release.keystore").exists()) {
signingConfig signingConfigs.release
}
}
@@ -72,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',
]
}
}
}
@@ -89,12 +93,20 @@ 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"

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

@@ -1,5 +1,6 @@
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
@@ -7,6 +8,7 @@ import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
import kotlinx.serialization.Serializable
import kotlinx.coroutines.coroutineScope
@@ -48,7 +50,11 @@ object BackupOperation {
* @param config backup configuration
* @param outputDir root output directory
* @param userId Android user ID (0, 999, etc.)
* @param onProgress callback for UI updates
* @param includePkgs if non-empty, only backup apps whose package name is in this set;
* metadata (app_details.json, appList.txt) is still generated for all [apps].
* @param legacyApps metadata from a previous snapshot used to populate app_details.json
* for apps not in [apps] (keeps them in the cumulative snapshot record
* without requiring re-scans of possibly-uninstalled apps).
*/
suspend fun backupApps(
context: android.content.Context,
@@ -56,6 +62,9 @@ object BackupOperation {
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) } }
@@ -66,28 +75,31 @@ object BackupOperation {
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.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.mapIndexed { index, app ->
backupTargets.mapIndexed { index, app ->
async {
semaphore.withPermit {
ensureActive()
val appDir = File(backupRoot, app.packageName.value)
appDir.mkdirs()
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "apk", "正在备份 APK…"))
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "apk", "正在备份 APK…"))
// 1. Backup APK
val paths = AppScanner.getApkPaths(app.packageName.value)
@@ -100,23 +112,27 @@ object BackupOperation {
if (!apkOk) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "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, apps.size, app.packageName.value, "data", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失"))
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.value, "data", "正在备份数据…"))
if (!backupUserData(context, app.packageName.value, appDir, userId, config.compressionMethod)) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "done", "数据备份失败"))
return@withPermit
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
}
}
}
@@ -124,17 +140,17 @@ object BackupOperation {
if (config.backupMode == 1 && config.backupObbData == 1) {
val hasObb = AppScanner.hasObbData(app.packageName.value)
if (hasObb) {
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "obb", "正在备份 OBB…"))
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.value, "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.value, "ssaid", "正在备份 SSAID…"))
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "ssaid", "正在备份 SSAID…"))
backupSsaid(app.packageName.value, appDir, userId)
// 4.5 Backup app icon
@@ -147,7 +163,7 @@ object BackupOperation {
backupPermissions(app.packageName.value, appDir)
successAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "done", "完成"))
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "完成"))
}
}
}.awaitAll()
@@ -337,14 +353,36 @@ 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)
// 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,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,231 +0,0 @@
package com.example.androidbackupgui.backup
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import java.io.File
/**
* Manages remote transport lifecycle (SMB/WebDAV) and local temp repo sync.
*
* For SMB/WebDAV backends, restic runs against a local temp directory;
* [RemoteTransport] syncs files to/from the remote backend.
*
* All sync operations are serialized via [repoSyncMutex] so concurrent
* operations don't corrupt the local temp repo.
*/
class RemoteSyncManager {
private sealed interface SyncEvent {
data class Phase(val progress: RemoteTransport.TransferProgress) : SyncEvent
data class Bytes(val progress: RemoteTransport.ByteProgress) : SyncEvent
}
private val TAG = "ResticWrapper"
/** Local temp directory used as restic repo for SMB/WebDAV backends. */
@Volatile
var tempRepoDir: String = ""
/** Domain for SMB NTLM authentication. */
@Volatile
var backendDomain: String = ""
// ── Transport cache ──────────────────────────────────
@Volatile private var transport: RemoteTransport? = null
private var transportConfigKey: String = ""
private val transportLock = Any()
/** Serializes access to tempRepoDir so concurrent operations don't corrupt each other. */
private val repoSyncMutex = Mutex()
// ── Transport lifecycle ──────────────────────────────
private fun ensureTransport(
backend: String, url: String, user: String, pass: String, share: String, repoPath: String
): RemoteTransport? = synchronized(transportLock) {
val key = "$backend|$url|$user|${pass.hashCode()}|$share|$backendDomain|$repoPath"
if (key != transportConfigKey || transport == null) {
transport?.let { Log.i(TAG, "transport config changed, recreating") }
// Clear local temp repo when backend config changes so
// syncFromRemote downloads fresh data from the new backend
if (transportConfigKey.isNotEmpty() && tempRepoDir.isNotEmpty()) {
val dir = File(tempRepoDir)
val deleted = dir.deleteRecursively()
Log.i(TAG, "cleared local temp repo: $tempRepoDir (deleted=$deleted)")
dir.mkdirs()
}
transport = RemoteTransport.create(backend, url, user, pass, share, backendDomain)
if (transport != null) {
transportConfigKey = key
Log.i(TAG, "transport created: $backend @ $url repo=$repoPath domain=$backendDomain")
} else {
Log.e(TAG, "transport creation failed for backend=$backend url=$url")
}
}
return transport
}
// ── Temp dir lifecycle ───────────────────────────────
/** Clean up local temp repo and cache directories. */
private fun cleanupTempDirs() {
if (tempRepoDir.isEmpty()) return
try {
val repoDir = File(tempRepoDir)
if (repoDir.exists()) {
val deleted = repoDir.deleteRecursively()
Log.i(TAG, "cleanupTempDirs: deleted $tempRepoDir ($deleted)")
}
val cacheDir = File(tempRepoDir.substringBeforeLast("/") + "/restic_cache")
if (cacheDir.exists()) {
val deleted = cacheDir.deleteRecursively()
Log.i(TAG, "cleanupTempDirs: deleted cache $cacheDir ($deleted)")
}
val tmpDir = File(tempRepoDir.substringBeforeLast("/") + "/restic_tmp")
if (tmpDir.exists()) {
val deleted = tmpDir.deleteRecursively()
Log.i(TAG, "cleanupTempDirs: deleted tmp $tmpDir ($deleted)")
}
} catch (e: Exception) {
Log.w(TAG, "cleanupTempDirs failed: ${e.message}")
}
}
/** True if [tempRepoDir] already contains an initialized restic repository (has a config file). */
private fun isLocalRepoPopulated(): Boolean {
if (tempRepoDir.isEmpty()) return false
return File(tempRepoDir, "config").isFile
}
// ── Sync engine ──────────────────────────────────────
/**
* Execute [action] with remote repo synced before/after as needed.
* For local/rest-server backends, executes [action] directly without sync.
* Protected by [repoSyncMutex] so concurrent operations don't corrupt tempRepoDir.
*
* Cleanup strategy:
* - Write ops (needsUpload=true): cleanup only on successful sync to remote.
* On syncToRemote failure the local repo is preserved so the next
* operation can retry — destroying it would lose the just-created snapshot.
* - Read-only ops (needsUpload=false): keep local cache for subsequent operations.
* - Read-only ops skip download entirely if local repo is already populated.
*/
suspend fun <T> withRemoteSync(
backend: String,
backendUrl: String,
backendUser: String,
backendPass: String,
backendShare: String,
repoPath: String,
needsDownload: Boolean,
needsUpload: Boolean,
onProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
action: suspend () -> AppResult<T>
): AppResult<T> {
if (backend != "smb" && backend != "webdav") return action()
return repoSyncMutex.withLock {
coroutineScope {
var shouldCleanup = false
var lastByteEmitMs = 0L
val progressChannel = Channel<SyncEvent>(CONFLATED)
val progressJob = launch(Dispatchers.Main) {
for (event in progressChannel) {
when (event) {
is SyncEvent.Phase -> onProgress(event.progress)
is SyncEvent.Bytes -> {
val now = System.currentTimeMillis()
if (now - lastByteEmitMs >= 50) {
onByteProgress(event.progress)
lastByteEmitMs = now
}
}
}
}
}
try {
val t = ensureTransport(backend, backendUrl, backendUser, backendPass, backendShare, repoPath)
?: return@coroutineScope err(AppError.Remote("Failed to create transport for backend: $backend", "connecting"))
val localDir = File(tempRepoDir)
val emitProgress: suspend (RemoteTransport.TransferProgress) -> Unit = { p ->
progressChannel.send(SyncEvent.Phase(p))
}
val emitByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit = { p ->
progressChannel.send(SyncEvent.Bytes(p))
}
// Write ops always download to avoid overwriting remote changes.
// Read-only ops skip download if local repo is already present.
val actualDownload = needsDownload && (needsUpload || !isLocalRepoPopulated())
if (actualDownload) {
Log.i(TAG, "syncFromRemote start: $repoPath -> $tempRepoDir")
val syncResult = RemoteTransport.syncFromRemote(t, localDir, repoPath, emitProgress, emitByteProgress)
if (syncResult.isFailure) {
shouldCleanup = true
Log.e(TAG, "syncFromRemote FAILED: ${syncResult.exceptionOrNull()?.message}")
return@coroutineScope err(AppError.Remote("syncFromRemote failed: ${syncResult.exceptionOrNull()?.message}", "download"))
}
Log.i(TAG, "syncFromRemote complete")
} else if (needsDownload) {
Log.i(TAG, "syncFromRemote skipped: local repo already populated")
}
val result = action()
if (needsUpload && result.isSuccess) {
Log.i(TAG, "syncToRemote start: $tempRepoDir -> $repoPath")
val uploadResult = RemoteTransport.syncToRemote(t, localDir, repoPath, emitProgress, emitByteProgress)
if (uploadResult.isFailure) {
shouldCleanup = false // PRESERVE local repo — snapshot would be lost
Log.e(TAG, "syncToRemote FAILED: ${uploadResult.exceptionOrNull()?.message} — local repo preserved for retry")
return@coroutineScope err(AppError.Remote("syncToRemote failed: ${uploadResult.exceptionOrNull()?.message}", "upload"))
}
Log.i(TAG, "syncToRemote complete")
shouldCleanup = true
} else if (result.isFailure) {
shouldCleanup = true
}
result
} catch (e: CancellationException) {
shouldCleanup = true
throw e
} catch (e: Exception) {
shouldCleanup = true
err(AppError.Remote(e.message ?: "Unknown error", "sync", cause = e))
} finally {
progressChannel.close()
progressJob.join()
if (shouldCleanup) {
Log.i(TAG, "withRemoteSync: cleaning up temp dirs")
cleanupTempDirs()
} else {
Log.d(TAG, "withRemoteSync: keeping local repo for subsequent ops")
}
}
}
}
}
/**
* Public safety-net cleanup called by fragment lifecycle.
* Waits for any in-progress operation to finish, then deletes temp dirs.
*/
suspend fun cleanup() {
repoSyncMutex.withLock { cleanupTempDirs() }
}
}

View File

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

@@ -8,20 +8,23 @@ 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
/**
* 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 = "ResticBackup"
var cacheDir: String = ""
var backendDomain: String = ""
// ── Backup ─────────────────────────────────────────
@@ -36,35 +39,103 @@ class ResticBackup(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
): AppResult<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = true,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
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 err(AppError.Restic("restic backup 失败", result.exitCode, 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)
}
}
}

View File

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

View File

@@ -158,6 +158,90 @@ class ResticCommandRunner {
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)
}
}
}
/**

View File

@@ -5,32 +5,38 @@ 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
// Restic needs a writable temp dir for pack files. Android has no /tmp.
val tmpDir = tempRepoDir.substringBeforeLast("/") + "/restic_tmp"
val tmpDir = "$cacheDir/restic_tmp"
env["TMPDIR"] = tmpDir
}
return env
}
/** Build environment for local repository. */
fun buildLocalEnv(
repoPath: String,
password: String,
cacheDir: String
): Map<String, String> {
val env = HashMap(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

@@ -5,20 +5,32 @@ 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(
@@ -29,19 +41,23 @@ class ResticMaintenance(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<String> =
withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = true,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
if (backend == "local") {
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runRestic(env, "prune")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr))
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl ->
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))
}
}
}
@@ -55,19 +71,23 @@ class ResticMaintenance(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<String> =
withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = false,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
if (backend == "local") {
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runRestic(env, "check")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic check 失败", result.exitCode, result.stderr))
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl ->
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))
}
}
}
@@ -81,19 +101,23 @@ class ResticMaintenance(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<String> =
withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = false,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
if (backend == "local") {
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
val result = runner.runRestic(env, "stats")
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic stats 失败", result.exitCode, result.stderr))
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir)
) { bridgeUrl ->
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

@@ -6,20 +6,30 @@ 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(
@@ -30,38 +40,95 @@ class ResticRepoInit(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<Unit> =
withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = true,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
val result = runner.runRestic(env, "init")
// exitCode 0 = brand new repo created, needs upload
if (result.exitCode == 0) {
return@withRemoteSync AppResult.Success(Unit)
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 AppResult.Success(Unit)
}
// Config exists but repo is corrupted (wrong password, missing keys, etc.)
return@withRemoteSync err(
AppError.Restic("仓库已存在但无法验证", verify.exitCode, verify.stderr)
)
}
err(AppError.Restic("restic init 失败", result.exitCode, result.stderr))
}
}
/** 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,381 @@
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.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 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", ""
)
}
}
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

@@ -15,15 +15,31 @@ import com.example.androidbackupgui.backup.err
*
* 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 = {}
): 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 {
@@ -69,11 +80,48 @@ class ResticRestore(
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 = {},
backendShare: 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, "dump", snapshotId, filePath)
if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))
} else {
bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare, backendDomain,
repoPath, File(cacheDir)
) { bridgeUrl ->
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

@@ -5,21 +5,31 @@ 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
/**
* 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,22 +41,16 @@ class ResticSnapshotOps(
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<List<ResticWrapper.ResticSnapshot>> = withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = false,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
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 err(AppError.Restic("restic snapshots 失败", result.exitCode, result.stderr))
return@withContext err(AppError.Restic("restic snapshots 失败", result.exitCode, result.stderr))
}
try {
@@ -54,9 +58,33 @@ class ResticSnapshotOps(
result.stdout.ifEmpty { "[]" }
)
AppResult.Success(snapshots.sortedByDescending { it.time })
} catch (e: Exception) {
} catch (e: Exception) {
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 = {},
): AppResult<String> = withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = true,
onProgress = onSyncProgress,
onByteProgress = onByteSyncProgress,
) {
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) 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,6 +5,7 @@ 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
@@ -18,13 +19,14 @@ import com.example.androidbackupgui.backup.err
* Uses environment variables (RESTIC_REPOSITORY, RESTIC_PASSWORD) rather than
* command-line flags to avoid leaking secrets in the process list.
*
* For SMB/WebDAV backends, restic runs against a local temp directory;
* RemoteTransport syncs files to/from the remote backend.
* For SMB/WebDAV backends, restic connects via a local REST bridge
* ([ResticRestBridge]) that translates HTTP requests to [RemoteTransport] calls,
* eliminating the need for a local staging repo and full-directory sync.
*
* All public methods are suspend and run on Dispatchers.IO.
*
* This object is a facade that delegates to [ResticCommandRunner],
* [ResticEnvResolver], [RemoteSyncManager], and sub-module classes
* [ResticEnvResolver], [RestBridgeRunner], and sub-module classes
* ([ResticRepoInit], [ResticBackup], [ResticRestore], [ResticSnapshotOps],
* [ResticMaintenance]).
*/
@@ -34,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 ───────────────────────────
@@ -51,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
@@ -84,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(
@@ -94,11 +115,8 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<Unit> = repoInit.init(
repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare
)
// ── Backup ─────────────────────────────────────────
@@ -132,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 = {}
): 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 ────────────────────────────────────────
@@ -154,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 = {}
): AppResult<Unit> = restoreOp.restore(
repoPath, password, snapshotId, targetPath, include,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress, onProgress
onProgress
)
// ── File dump ──────────────────────────────────────
@@ -175,12 +210,9 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<String> = restoreOp.dump(
repoPath, password, snapshotId, filePath,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
backend, backendUrl, backendUser, backendPass, backendShare
)
// ── Snapshot management ────────────────────────────
@@ -194,12 +226,9 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<List<ResticSnapshot>> = snapshotOps.listSnapshots(
repoPath, password, tag,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
backend, backendUrl, backendUser, backendPass, backendShare
)
suspend fun forget(
@@ -214,14 +243,81 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<String> = snapshotOps.forget(
repoPath, password, keepDaily, keepWeekly, keepMonthly, dryRun,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
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(
@@ -232,12 +328,9 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<String> = maintenance.prune(
repoPath, password,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
backend, backendUrl, backendUser, backendPass, backendShare
)
suspend fun check(
@@ -248,12 +341,9 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<String> = maintenance.check(
repoPath, password,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
backend, backendUrl, backendUser, backendPass, backendShare
)
suspend fun stats(
@@ -264,12 +354,9 @@ object ResticWrapper {
backendUser: String = "",
backendPass: String = "",
backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): AppResult<String> = maintenance.stats(
repoPath, password,
backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress
backend, backendUrl, backendUser, backendPass, backendShare
)
// ── Public URL helper ──────────────────────────────
@@ -278,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

@@ -14,6 +14,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import java.io.File
import java.util.Properties
import java.util.concurrent.atomic.AtomicBoolean
class SmbTransport(
private val host: String,
@@ -22,10 +23,21 @@ class SmbTransport(
private val password: String,
private val domain: String = "",
private val bufferSize: Int = 8192,
private val smbSigning: Boolean = true
private val smbSigning: Boolean = false
): RemoteTransport {
companion object { private const val TAG = "SmbTransport" }
companion object {
private const val TAG = "SmbTransport"
/** Register missing JCA algorithms for jcifs-ng (MD4, AESCMAC, etc.). */
private val patchesRegistered = AtomicBoolean(false)
fun registerMissingAlgorithms() {
if (patchesRegistered.compareAndSet(false, true)) {
MissingAlgoProvider.register()
}
}
}
private val context: CIFSContext by lazy {
registerMissingAlgorithms()
val props = Properties().apply {
// Force SMB 2.0.2 minimum — SMB1 is disabled on modern Windows
setProperty("jcifs.smb.client.minVersion", "SMB202")
@@ -33,7 +45,7 @@ class SmbTransport(
// Shorter timeouts for Android
setProperty("jcifs.smb.client.responseTimeout", "15000")
setProperty("jcifs.smb.client.connTimeout", "10000")
// Enable SMB signing for security (prevents tampering) — disable for legacy servers
// SMB signing (disabled by default — most home servers don't support it)
if (smbSigning) {
setProperty("jcifs.smb.client.signingEnabled", "true")
setProperty("jcifs.smb.client.encryptionEnabled", "true")
@@ -47,7 +59,9 @@ class SmbTransport(
}
}
/** Build a full SMB URL. If [path] is already a full URL, pass through. */
private fun buildUrl(path: String): String {
if (path.startsWith("smb://")) return path
val cleanPath = path.trimStart('/')
val sharePath = if (share.isNotEmpty()) "$share/$cleanPath" else cleanPath
return "smb://$host/$sharePath"
@@ -82,8 +96,18 @@ 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)")
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
@@ -184,15 +208,15 @@ class SmbTransport(
} catch (e: SmbException) {
// STATUS_OBJECT_NAME_COLLISION (0xC0000035): directory already exists — not an error
if (e.ntStatus == 0xC0000035.toInt()) {
AppResult.Success(Unit)
AppResult.Success(Unit)
} else {
Log.e(TAG, "mkdirs failed: $remotePath${e.message}")
Log.e(TAG, "mkdirs failed: $remotePathntStatus=0x${e.ntStatus.toString(16)} msg=${e.message} cause=${e.cause}")
err(AppError.Remote("SMB 创建目录失败", "mkdirs", cause = e))
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "mkdirs failed: $remotePath${e.message}")
Log.e(TAG, "mkdirs failed: $remotePath${e::class.java.name}: ${e.message} cause=${e.cause?.message}")
err(AppError.Remote("SMB 创建目录失败", "mkdirs", cause = e))
}
}

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

@@ -12,6 +12,7 @@ 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
@@ -20,11 +21,16 @@ import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.ResticWrapper
import com.example.androidbackupgui.backup.WifiManager
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.RemoteTransport
import com.example.androidbackupgui.databinding.FragmentBackupBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.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
@@ -41,6 +47,7 @@ class BackupFragment : Fragment() {
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 }
@@ -56,10 +63,12 @@ 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
@@ -110,6 +119,11 @@ class BackupFragment : Fragment() {
override fun onResume() {
super.onResume()
if (::config.isInitialized) {
val configFile = File(requireContext().filesDir, "backup_settings.conf")
config = BackupConfig.fromFile(configFile)
updateOutputPathDisplay()
}
}
private fun scanApps() {
@@ -149,27 +163,25 @@ class BackupFragment : Fragment() {
setupAppList()
binding.statusText.text = "已选择 ${selectedApps.size}/${sortedApps.size} 个应用"
}
private fun setupAppList() {
val displayApps = sortedApps.ifEmpty { apps }
binding.appList.adapter = PackageListAdapter(displayApps, selectedApps) { pkg, checked ->
if (checked) selectedApps.add(pkg) else selectedApps.remove(pkg)
binding.statusText.text = "已选择 ${selectedApps.size}/${displayApps.size} 个应用"
}
binding.appList.adapter = PackageListAdapter(
displayApps, selectedApps,
onToggle = { pkg, checked ->
if (checked) selectedApps.add(pkg) else selectedApps.remove(pkg)
binding.statusText.text = "已选择 ${selectedApps.size}/${displayApps.size} 个应用"
},
excludeDataFrom = excludeDataFromBackup,
onExcludeDataToggle = { pkg, excluded ->
if (excluded) excludeDataFromBackup.add(pkg) else excludeDataFromBackup.remove(pkg)
}
)
}
private fun startBackup() {
val toBackup = apps.filter { it.packageName.value in selectedApps }
if (toBackup.isEmpty()) return
// Check restic local repo availability before doing any work
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank() &&
config.resticBackend == "local" && !File(config.resticRepo, "config").exists()
) {
binding.statusText.text = "restic 本地仓库未初始化,请先在设置中初始化"
return
}
setRunning(true)
binding.backupButton.isEnabled = false
binding.scanButton.isEnabled = false
@@ -187,14 +199,108 @@ class BackupFragment : Fragment() {
val outputDir = File(config.outputPath.ifEmpty {
requireContext().filesDir.absolutePath
})
// ── Restic pre-flight: load snapshot metadata for cumulative merge ──
var snapshotApps: Map<String, ResticWrapper.SnapshotAppInfo>? = null
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
updateStatus("正在检查 restic 历史快照…")
if (config.resticBackend == "local" && !File(config.resticRepo, "config").exists()) {
updateStatus("restic 本地仓库未初始化,请先在设置中初始化")
return@launch
}
val binaryPath = ResticBinary.prepare(requireContext())
if (binaryPath != null) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.cacheDir = requireContext().cacheDir.absolutePath
ResticWrapper.backendDomain = config.resticBackendDomain
snapshotApps = ResticWrapper.getLatestSnapshotAppDetails(
repoPath = config.resticRepo,
password = config.resticPassword,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare
)
if (snapshotApps != null) {
updateStatus("发现历史快照,将合并为累积备份")
}
}
}
// ── Build merged app list for cumulative snapshot ──
val selectedPkgs = toBackup.map { it.packageName.value }.toSet()
val allApps: List<AppInfo>
val includePkgs: Set<String>
if (snapshotApps != null) {
// Create placeholder AppInfo entries for packages from the snapshot
// that are NOT in the current selection. These won't be re-backed-up
// but their metadata is preserved via legacyApps.
val snapshotOnly = snapshotApps.keys.filter { it !in selectedPkgs }
val legacyEntries = snapshotOnly.mapNotNull { pkg ->
val snap = snapshotApps[pkg] ?: return@mapNotNull null
AppInfo(
packageName = PackageName(pkg),
label = snap.label,
isSystem = snap.isSystem
)
}
allApps = toBackup + legacyEntries
includePkgs = selectedPkgs
val snapCount = legacyEntries.size
if (snapCount > 0) {
updateStatus("累积备份: ${allApps.size} 个应用 ($snapCount 个来自历史快照)")
}
// Restore latest snapshot to populate directories for unchanged apps
updateStatus("正在恢复历史快照…")
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_${selectedUserId}")
backupRoot.mkdirs()
val snapsResult = ResticWrapper.listSnapshots(
repoPath = config.resticRepo,
password = config.resticPassword,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare
)
val latestSnap = (snapsResult as? AppResult.Success)?.data?.firstOrNull()
if (latestSnap != null) {
ResticWrapper.restore(
repoPath = config.resticRepo,
password = config.resticPassword,
snapshotId = latestSnap.shortId,
targetPath = backupRoot.absolutePath,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare
)
}
} else {
allApps = toBackup
includePkgs = emptySet()
}
// ── Execute backup (with cumulative metadata) ──
updateStatus("正在备份: ${allApps.size} 个应用…")
val result = BackupOperation.backupApps(
context = requireContext(),
apps = toBackup,
apps = allApps,
config = config,
outputDir = outputDir,
userId = selectedUserId.toString(),
noDataBackup = excludeDataFromBackup.toSet(),
includePkgs = includePkgs,
legacyApps = snapshotApps,
onProgress = { progress ->
val label = toBackup.find { it.packageName.value == progress.packageName }?.label
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}")
}
@@ -210,7 +316,7 @@ class BackupFragment : 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
if (config.resticBackend == "local") {
@@ -231,19 +337,6 @@ class BackupFragment : Fragment() {
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onSyncProgress = { progress: RemoteTransport.TransferProgress ->
if (progress.phase in listOf("list", "download", "upload", "delete_stale")) {
updateStatus("同步中: ${progress.current}/${progress.total} 个文件")
}
},
onByteSyncProgress = { progress ->
withContext(Dispatchers.Main) {
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
binding.progressBar.progress = progress.bytesTransferred.toInt()
}
updateStatus("同步中: ${progress.currentFile}\n" +
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}")
},
onProgress = { progress ->
if (progress.messageType == "status") {
updateStatus("去重仓库: %.0f%% (%d/%d 个文件)".format(
@@ -259,16 +352,17 @@ class BackupFragment : Fragment() {
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()
@@ -312,13 +406,142 @@ class BackupFragment : Fragment() {
withContext(Dispatchers.Main) { binding.statusText.text = text }
}
override fun onDestroyView() {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
ResticWrapper.cleanup()
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
}
}
super.onDestroyView()
_binding = null
}
/**
* Run streaming backup via [StreamingBackup] + [ResticWrapper.backupStdin].
* Used when staging space is insufficient.
*/
@Suppress("UNUSED_PARAMETER")
private suspend fun runStreamingResticBackup(
config: com.example.androidbackupgui.backup.BackupConfig,
apps: List<com.example.androidbackupgui.backup.AppInfo>,
outputDir: File,
cacheDir: String
): ResticWrapper.BackupSummary? {
updateStatus("空间不足,启动流式备份模式…")
val cacheDirFile = File(cacheDir, "streaming_tmp")
cacheDirFile.mkdirs()
// Prepare streaming: create FIFO, metadata, collect APK paths
val streamingResult = StreamingBackup.prepareStreaming(
cacheDirFile, apps, null
)
// Start restic with stdin from FIFO, in parallel with data producer
var summary: ResticWrapper.BackupSummary? = null
var backupError: String? = null
coroutineScope {
// Launch restic backup (consumer)
val resticJob = async {
val result = ResticWrapper.backupStdin(
repoPath = config.resticRepo,
password = config.resticPassword,
stdinFile = streamingResult.dataFifo,
extraPaths = streamingResult.apkPaths + streamingResult.metaDir.absolutePath,
tags = listOf("streaming_${System.currentTimeMillis() / 1000}"),
hostname = "android-backup-gui",
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onProgress = { progress ->
if (progress.messageType == "status") {
updateStatus("流式去重仓库: %.0f%% (%d/%d 个文件)".format(
progress.percentDone * 100,
progress.filesDone,
progress.totalFiles
))
}
}
)
when (result) {
is AppResult.Success -> summary = result.data
is AppResult.Failure -> backupError = result.error.message
}
}
// Launch data producer (writes tar to FIFO)
val producerJob = async {
StreamingBackup.launchDataProducer(
apps = apps,
noDataBackup = excludeDataFromBackup.toSet(),
userId = selectedUserId.toString(),
fifoPath = streamingResult.dataFifo.absolutePath
)
}
// Wait for both to complete
producerJob.await()
resticJob.await()
}
// Cleanup FIFO
try { streamingResult.dataFifo.delete() } catch (_: Exception) {}
try { streamingResult.metaDir.deleteRecursively() } catch (_: Exception) {}
if (backupError != null) {
updateStatus("流式备份失败: $backupError")
}
return summary
}
}

View File

@@ -247,13 +247,4 @@ class ConfigFragment : Fragment() {
vm.pruneResticSnapshots(readResticForm())
}
override fun onDestroyView() {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
ResticWrapper.cleanup()
}
}
super.onDestroyView()
_binding = null
}
}

View File

@@ -8,7 +8,6 @@ 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
@@ -18,9 +17,9 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
/** UI-visible state driven by [ConfigViewModel]. */
data class ConfigUiState(
@@ -107,6 +106,8 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
private val _uiState = MutableStateFlow(ConfigUiState())
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow()
/** Guards against concurrent [initResticRepo] calls. */
private val initGuard = AtomicBoolean(false)
/** Read config from file and refresh restic status. */
fun load() {
val config = BackupConfig.fromFile(configFile)
@@ -154,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()) {
@@ -188,24 +193,23 @@ 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 (result.isSuccess) {
_operationEvents.emit(OperationEvent.InitCompleted)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "仓库初始化成功: ${form.repo}", initButtonEnabled = true
message = "仓库初始化成功: ${form.repo}"
))}
refreshResticStatus(form)
} else {
_operationEvents.emit(OperationEvent.InitFailed)
Log.e(TAG, "initResticRepo failed: ${result.exceptionOrNull()?.message}")
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "初始化失败: ${result.exceptionOrNull()?.message}", initButtonEnabled = true
message = "初始化失败: ${result.exceptionOrNull()?.message}"
))}
refreshResticStatus(form)
}
} finally {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(initButtonEnabled = true)) }
initGuard.set(false)
}
}
}
@@ -235,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())
@@ -265,15 +267,11 @@ 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) },
)
val snapshotsResult = ResticWrapper.listSnapshots(form.repo, form.password,
backend = form.backend, backendUrl = form.backendUrl,
backendUser = form.backendUser, backendPass = form.backendPass,
backendShare = form.backendShare,
onSyncProgress = { p -> onSyncProgress(p) },
onByteSyncProgress = { p -> onByteProgress(p) },
)
val snapshotCount = snapshotsResult.getOrDefault(emptyList()).size
@@ -310,8 +308,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 (forgetResult.isFailure) {
_operationEvents.emit(OperationEvent.PruneFailed)
@@ -328,8 +324,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) },
)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = if (pruneResult.isSuccess)
@@ -349,29 +343,5 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
}
}
// ── Internal progress helpers ─────────────────────────────────────
private fun onSyncProgress(p: RemoteTransport.TransferProgress) {
_uiState.update {
it.copy(resticStatus = it.resticStatus.copy(
message = "同步中: ${p.current}/${p.total} 个文件"
))
}
}
private fun onByteProgress(p: RemoteTransport.ByteProgress) {
_uiState.update {
it.copy(resticStatus = it.resticStatus.copy(
message = "同步中: ${p.currentFile}\n${formatSize(p.bytesTransferred)} / ${formatSize(p.totalBytes)}"
))
}
}
/** Cleanup ResticWrapper resources when ViewModel is cleared. */
override fun onCleared() {
super.onCleared()
runBlocking(Dispatchers.IO) {
ResticWrapper.cleanup()
}
}
}

View File

@@ -19,12 +19,15 @@ 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 {
@@ -55,21 +58,67 @@ class PackageListAdapter(
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.value }
holder.textView.text = app.label.ifEmpty { pkg }
// Avoid re-triggering listener during bind
holder.checkbox.setOnCheckedChangeListener(null)
holder.checkbox.isChecked = app.packageName.value in selected
holder.checkbox.isChecked = pkg in selected
holder.checkbox.setOnCheckedChangeListener { _, checked ->
onToggle(app.packageName.value, 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

@@ -18,13 +18,11 @@ import com.example.androidbackupgui.backup.RestoreOperation
import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.ResticWrapper
import com.example.androidbackupgui.backup.WifiManager
import com.example.androidbackupgui.backup.RemoteTransport
import com.example.androidbackupgui.databinding.FragmentRestoreBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import com.example.androidbackupgui.backup.formatSize
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
@@ -64,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
}
@@ -120,15 +118,14 @@ class RestoreFragment : Fragment() {
// Skip redundant preparation if binary and backend config are already set
if (resticConfig != null &&
ResticWrapper.binaryPath.isNotEmpty() &&
ResticWrapper.binaryPath != "restic" &&
ResticWrapper.backendDomain == config.resticBackendDomain
ResticWrapper.binaryPath != "restic"
) {
binding.selectResticButton.visibility = View.VISIBLE
} else {
val binaryPath = ResticBinary.prepare(requireContext())
if (binaryPath != null && resticConfig != null) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
ResticWrapper.cacheDir = requireContext().cacheDir.absolutePath
ResticWrapper.backendDomain = config.resticBackendDomain
binding.selectResticButton.visibility = View.VISIBLE
}
@@ -189,12 +186,6 @@ class RestoreFragment : Fragment() {
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onSyncProgress = { p ->
updateStatus("同步中: ${p.current}/${p.total} [${p.currentFile}]")
},
onByteSyncProgress = { bp ->
updateStatus("下载中: ${bp.bytesTransferred / 1024 / 1024} MB / ${bp.totalBytes / 1024 / 1024} MB")
}
)
if (snapshotsResult.isFailure) {
updateStatus("读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}")
@@ -292,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() {
@@ -329,19 +323,6 @@ class RestoreFragment : Fragment() {
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onSyncProgress = { progress: RemoteTransport.TransferProgress ->
if (progress.phase in listOf("list", "download", "upload", "delete_stale")) {
updateStatus("同步中: ${progress.current}/${progress.total} 个文件")
}
},
onByteSyncProgress = { progress ->
withContext(Dispatchers.Main) {
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
binding.progressBar.progress = progress.bytesTransferred.toInt()
}
updateStatus("同步中: ${progress.currentFile}\n" +
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}")
},
onProgress = { msg -> withContext(Dispatchers.Main) { binding.statusText.text = msg } }
)

View File

@@ -50,6 +50,39 @@
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"

View File

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