20 Commits

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

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

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

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

Add fallback cleanup in ConfigFragment.onDestroyView().
2026-06-04 21:18:24 +08:00
51 changed files with 4335 additions and 1530 deletions

1
.gitignore vendored
View File

@@ -22,3 +22,4 @@ memory:*
# Restic test repository (contains encryption keys)
test/
kmboxnet

View File

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

@@ -1,6 +1,23 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
apply plugin: 'org.jetbrains.kotlinx.kover'
kover {
reports {
filters {
excludes {
classes(
// Generated/auto classes
"*.databinding.*",
"*.BuildConfig",
"*.R",
"*.R\$*"
)
}
}
}
}
android {
namespace "com.example.androidbackupgui"
@@ -9,8 +26,8 @@ android {
applicationId "com.example.androidbackupgui"
minSdk 24
targetSdk 34
versionCode 2
versionName "1.1"
versionCode 11
versionName "1.10"
}
buildFeatures {
viewBinding true
@@ -20,24 +37,27 @@ 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
}
}
}
testOptions {
unitTests.all {
useJUnitPlatform()
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
@@ -49,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',
]
}
}
}
@@ -66,10 +93,23 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
// 方案A: jcifs-ng (SMB) + sardine-android (WebDAV) 替代 rclone serve
implementation "eu.agno3.jcifs:jcifs-ng:2.1.10"
implementation "com.github.thegrizzlylabs:sardine-android:v0.9"
implementation("eu.agno3.jcifs:jcifs-ng:2.1.10") {
exclude group: 'org.bouncycastle'
}
implementation("com.github.thegrizzlylabs:sardine-android:v0.9") {
exclude group: 'xpp3'
exclude group: 'stax'
}
implementation "org.slf4j:slf4j-android:1.7.36"
// root shell via libsu (Magisk/KernelSU/APatch)
implementation 'com.github.topjohnwu:libsu:6.0.0'
// Full BouncyCastle provider (includes MD4 required by jcifs-ng SMB)
implementation 'org.bouncycastle:bcprov-jdk15to18:1.77'
implementation 'org.nanohttpd:nanohttpd:2.3.1'
testImplementation "io.kotest:kotest-runner-junit5:5.9.1"
testImplementation "io.kotest:kotest-assertions-core:5.9.1"
testImplementation "io.kotest:kotest-property:5.9.1"
testImplementation "io.mockk:mockk:1.13.12"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
}

View File

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

View File

@@ -19,6 +19,7 @@ import com.example.androidbackupgui.ui.RestoreFragment
import com.google.android.material.color.DynamicColors
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : AppCompatActivity() {
@@ -39,17 +40,43 @@ class MainActivity : AppCompatActivity() {
RootShell.configure()
// Request root access on startup
lifecycleScope.launch(Dispatchers.IO) {
RootShell.ensureSession()
// Initialize file-based logging
LogUtil.init(filesDir)
lifecycleScope.launch {
withContext(Dispatchers.IO) {
RootShell.ensureSession()
}
// Initialize file-based logging
LogUtil.init(filesDir)
}
// Edge-to-edge: pad toolbar below status bar
ViewCompat.setOnApplyWindowInsetsListener(binding.topAppBar) { view, insets ->
// Edge-to-edge: distribute system bar insets (status bar, nav bar, cutout) to children
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets ->
val statusBars = insets.getInsets(WindowInsetsCompat.Type.statusBars())
view.setPadding(view.paddingLeft, statusBars.top, view.paddingRight, view.paddingBottom)
val navBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
// Pad toolbar below status bar (preserve existing horizontal padding)
binding.topAppBar.setPadding(
binding.topAppBar.paddingLeft,
statusBars.top,
binding.topAppBar.paddingRight,
binding.topAppBar.paddingBottom
)
// Pad bottom nav above navigation bar so menu items are visible
binding.bottomNav.setPadding(
binding.bottomNav.paddingLeft,
binding.bottomNav.paddingTop,
binding.bottomNav.paddingRight,
navBars.bottom
)
// Pad view pager above navigation bar so fragment content doesn't overlap nav bar
binding.viewPager.setPadding(
binding.viewPager.paddingLeft,
binding.viewPager.paddingTop,
binding.viewPager.paddingRight,
navBars.bottom
)
insets
}
@@ -61,6 +88,7 @@ class MainActivity : AppCompatActivity() {
binding.viewPager.adapter = TabAdapter(this, fragments)
binding.viewPager.isUserInputEnabled = true
binding.viewPager.offscreenPageLimit = 2
binding.bottomNav.setOnItemSelectedListener { item ->
when (item.itemId) {

View File

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

View File

@@ -19,15 +19,15 @@ data class DataSizes(
@Serializable
data class AppInfo(
val packageName: String,
var label: String = "",
val packageName: PackageName,
val label: String = "",
val isSystem: Boolean = false,
val apkPaths: List<String> = emptyList(),
val hasObb: Boolean = false,
val isRunning: Boolean = false,
val backupSize: Long = 0, // estimated from last backup
// Enhanced fields (multi-user, keystore, icon)
val userId: Int = 0,
val userId: UserId = UserId(0),
val hasKeystore: Boolean = false,
val iconPath: String? = null,
val dataSizes: DataSizes = DataSizes(),
@@ -44,11 +44,10 @@ object AppScanner {
.filter { it.startsWith("package:") }
.map { it.removePrefix("package:").trim() }
.filter { it.isNotEmpty() }
.map { AppInfo(packageName = it, userId = userId) }
.map { AppInfo(packageName = PackageName(it), userId = UserId(userId)) }
resolveLabels(context, packages)
}
/** Scan all system packages. */
suspend fun scanSystem(context: Context, config: BackupConfig, userId: Int = 0): List<AppInfo> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list packages -s --user $userId")
if (!result.isSuccess) return@withContext emptyList()
@@ -67,7 +66,7 @@ object AppScanner {
.filter { pkg ->
if (config.blacklistMode == 1) pkg !in blacklist else true
}
.map { AppInfo(packageName = it, isSystem = true, userId = userId) }
.map { AppInfo(packageName = PackageName(it), isSystem = true, userId = UserId(userId)) }
resolveLabels(context, packages)
}
@@ -80,15 +79,15 @@ object AppScanner {
fun resolveLabels(context: Context, packages: List<AppInfo>): List<AppInfo> {
if (packages.isEmpty()) return packages
val pm = context.packageManager
for (app in packages) {
app.label = try {
val ai = pm.getApplicationInfo(app.packageName, 0)
return packages.map { app ->
val resolvedLabel = try {
val ai = pm.getApplicationInfo(app.packageName.value, 0)
pm.getApplicationLabel(ai).toString()
} catch (_: PackageManager.NameNotFoundException) {
app.packageName
app.packageName.value
}
app.copy(label = resolvedLabel)
}
return packages
}
/** Get APK paths for a package. */
@@ -127,7 +126,7 @@ object AppScanner {
/** Check if an app has keystore entries (critical — keystore keys can be lost on backup). */
suspend fun hasKeystore(packageName: String): Boolean = withContext(Dispatchers.IO) {
// Resolve the app's UID first
val uidResult = RootShell.exec("dumpsys package '$packageName' | grep 'userId=' | head -1")
val uidResult = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep 'userId=' | head -1")
val uid = uidResult.output
.substringAfter("userId=", "")
.substringBefore(" ")
@@ -156,11 +155,11 @@ object AppScanner {
suspend fun extractIcon(packageName: String, destDir: java.io.File, userId: Int = 0): String? = withContext(Dispatchers.IO) {
// Try snapshot cache first
val snapshotDir = "/data/system_ce/$userId/snapshots/$packageName"
val snapshotResult = RootShell.exec("ls '$snapshotDir/' 2>/dev/null | head -1")
val snapshotResult = RootShell.exec("ls '${snapshotDir.shellEscape()}/' 2>/dev/null | head -1")
if (snapshotResult.isSuccess && snapshotResult.output.isNotBlank()) {
val iconName = snapshotResult.output.trim()
val iconFile = java.io.File(destDir, "app_icon.png")
val copyResult = RootShell.exec("cp '${snapshotDir}/${iconName.shellEscape()}' '${iconFile.absolutePath.shellEscape()}' 2>/dev/null")
val copyResult = RootShell.exec("cp '${snapshotDir.shellEscape()}/${iconName.shellEscape()}' '${iconFile.absolutePath.shellEscape()}' 2>/dev/null")
if (copyResult.isSuccess && iconFile.exists()) {
return@withContext iconFile.absolutePath
}

View File

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

View File

@@ -1,17 +1,19 @@
package com.example.androidbackupgui.backup
import com.example.androidbackupgui.backup.ResticWrapper.SnapshotAppInfo
import com.example.androidbackupgui.root.RootShell
import android.util.Log
import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
import kotlin.coroutines.coroutineContext
import kotlinx.serialization.Serializable
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.util.concurrent.atomic.AtomicInteger
@@ -48,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,31 +75,34 @@ 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 })
appListFile.writeText(apps.joinToString("\n") { it.packageName.value })
// Write metadata JSON
// Write metadata JSON — fresh metadata for selected apps, legacy for historical apps
val metaFile = File(backupRoot, "app_details.json")
metaFile.writeText(buildAppDetailsJson(apps))
metaFile.writeText(buildAppDetailsJson(apps, legacyApps))
val backupTargets = if (includePkgs.isEmpty()) apps else apps.filter { it.packageName.value in includePkgs }
val totalCount = backupTargets.size
LogUtil.i(TAG, "backupApps: includePkgs=${includePkgs.size} targets=$totalCount")
val semaphore = Semaphore(3)
val successAtomic = AtomicInteger(0)
val failAtomic = AtomicInteger(0)
val skippedAtomic = AtomicInteger(0)
coroutineScope {
apps.forEachIndexed { index, app ->
launch {
if (!coroutineContext.isActive) return@launch
backupTargets.mapIndexed { index, app ->
async {
semaphore.withPermit {
val appDir = File(backupRoot, app.packageName)
ensureActive()
val appDir = File(backupRoot, app.packageName.value)
appDir.mkdirs()
emit(BackupProgress(index + 1, apps.size, app.packageName, "apk", "正在备份 APK…"))
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "apk", "正在备份 APK…"))
// 1. Backup APK
val paths = AppScanner.getApkPaths(app.packageName)
val paths = AppScanner.getApkPaths(app.packageName.value)
val apkOk = if (paths.isNotEmpty()) {
paths.withIndex().all { (i, apkPath) ->
val destName = if (paths.size > 1) "${app.packageName}_split_$i.apk" else "${app.packageName}.apk"
@@ -100,57 +112,61 @@ object BackupOperation {
if (!apkOk) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "APK 备份失败"))
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "APK 备份失败"))
return@withPermit
}
// 1.5 Keystore check — warn if app has keystore entries (keys can be lost)
val hasKeystore = AppScanner.hasKeystore(app.packageName)
val hasKeystore = AppScanner.hasKeystore(app.packageName.value)
if (hasKeystore) {
emit(BackupProgress(index + 1, apps.size, app.packageName, "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, "data", "正在备份数据…"))
if (!backupUserData(context, app.packageName, appDir, userId, config.compressionMethod)) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "数据备份失败"))
return@withPermit
if (app.packageName.value in noDataBackup) {
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "跳过数据备份(已排除)"))
} else {
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "正在备份数据…"))
if (!backupUserData(context, app.packageName.value, appDir, userId, config.compressionMethod)) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "数据备份失败"))
return@withPermit
}
}
}
// 3. Backup OBB (if configured and exists)
if (config.backupMode == 1 && config.backupObbData == 1) {
val hasObb = AppScanner.hasObbData(app.packageName)
val hasObb = AppScanner.hasObbData(app.packageName.value)
if (hasObb) {
emit(BackupProgress(index + 1, apps.size, app.packageName, "obb", "正在备份 OBB…"))
if (!backupObb(app.packageName, appDir, config.compressionMethod)) {
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "obb", "正在备份 OBB…"))
if (!backupObb(app.packageName.value, appDir, config.compressionMethod)) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "OBB 备份失败"))
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "OBB 备份失败"))
return@withPermit
}
}
}
// 4. Backup SSAID
emit(BackupProgress(index + 1, apps.size, app.packageName, "ssaid", "正在备份 SSAID…"))
backupSsaid(app.packageName, appDir, userId)
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "ssaid", "正在备份 SSAID…"))
backupSsaid(app.packageName.value, appDir, userId)
// 4.5 Backup app icon
val iconPath = AppScanner.extractIcon(app.packageName, appDir, app.userId)
val iconPath = AppScanner.extractIcon(app.packageName.value, appDir, app.userId.value)
if (iconPath != null) {
Log.d(TAG, "backupApps: saved icon for ${app.packageName} -> $iconPath")
}
// 5. Backup runtime permissions
backupPermissions(app.packageName, appDir)
backupPermissions(app.packageName.value, appDir)
successAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "完成"))
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "完成"))
}
}
}
}.awaitAll()
}
val elapsed = System.currentTimeMillis() - startTime
@@ -209,7 +225,7 @@ object BackupOperation {
var archiveCreated = false
var result: RootShell.ShellResult? = null
val dirs = dataPaths.filter { RootShell.exec("test -d $it").isSuccess }.toMutableList()
val dirs = dataPaths.filter { RootShell.exec("test -d '${it.shellEscape()}'").isSuccess }.toMutableList()
if (dirs.isNotEmpty()) {
Log.d(TAG, "backupUserData: $packageName test -d found dirs=$dirs")
result = runTar(dirs, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
@@ -227,9 +243,9 @@ object BackupOperation {
Log.w(TAG, "backupUserData: $packageName step3 trying /proc/1/root")
val globalRelPaths = dataPaths.map { it.removePrefix("/") }
val globalCmd = if (isZstd) {
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(" ")} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
"cd /proc/1/root && set -o pipefail; $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
} else {
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ")} 2>/dev/null"
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null"
}
result = RootShell.exec(globalCmd)
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
@@ -275,12 +291,12 @@ object BackupOperation {
excludes: List<String> = emptyList()
): RootShell.ShellResult {
val excludeArgs = if (excludes.isNotEmpty()) {
excludes.joinToString(" ") { "--exclude='$it'" }
excludes.joinToString(" ") { "--exclude='${it.shellEscape()}'" }
} else ""
return if (isZstd) {
RootShell.exec("$tarCmd -cf - $excludeArgs ${dirs.joinToString(" ")} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'")
RootShell.exec("set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'")
} else {
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ")} 2>/dev/null")
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null")
}
}
private suspend fun backupObb(packageName: String, appDir: File, compression: String): Boolean {
@@ -290,7 +306,7 @@ object BackupOperation {
// Exclude cache and backup temp files from OBB archive
val obbExcludes = "--exclude='cache' --exclude='Backup_*'"
val result = when (compression) {
"zstd" -> RootShell.exec("tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'")
"zstd" -> RootShell.exec("set -o pipefail; tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'")
else -> RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
}
if (!result.isSuccess) {
@@ -337,13 +353,35 @@ object BackupOperation {
}
}
private fun buildAppDetailsJson(apps: List<AppInfo>): String {
internal suspend fun buildAppDetailsJson(
apps: List<AppInfo>,
legacyApps: Map<String, SnapshotAppInfo>? = null
): String {
val root = JSONObject()
// Generate fresh metadata for apps in the current app list
for (app in apps) {
val entry = JSONObject()
entry.put("label", app.label)
entry.put("isSystem", app.isSystem)
root.put(app.packageName, entry)
// Record APK file sizes for change detection in incremental backup
val paths = AppScanner.getApkPaths(app.packageName.value)
val sizes = paths.map { path ->
val result = RootShell.exec("stat -c%s '${path.shellEscape()}'")
if (result.isSuccess) result.output.trim().toLongOrNull() ?: 0L else 0L
}
entry.put("apkSizes", JSONArray(sizes))
root.put(app.packageName.value, entry)
}
// Include legacy apps not in current app list with preserved metadata
val legacyMap = legacyApps ?: emptyMap()
for ((pkg, legacy) in legacyMap) {
if (!root.has(pkg)) {
val entry = JSONObject()
entry.put("label", legacy.label)
entry.put("isSystem", legacy.isSystem)
entry.put("apkSizes", JSONArray(legacy.apkSizes))
root.put(pkg, entry)
}
}
return root.toString(2)
}

View File

@@ -12,25 +12,28 @@ import java.io.File
object BinaryResolver {
private const val TAG = "BinaryResolver"
private val cacheTar = ResolveCache()
private val cacheZstd = ResolveCache()
private var tarPath: String? = null
private var zstdPath: String? = null
private class ResolveCache {
var initialized = false
var path: String? = null
fun tarPath(context: Context): String? = cacheOrResolve(context, "libtar_bin.so", "tar_bin", ::tarPath) { tarPath = it }
fun zstdPath(context: Context): String? = cacheOrResolve(context, "libzstd_bin.so", "zstd_bin", ::zstdPath) { zstdPath = it }
private fun cacheOrResolve(
context: Context, libName: String, destName: String,
cache: () -> String?, setCache: (String?) -> Unit
): String? {
val cached = cache()
if (cached != null) return cached
val resolved = resolve(context, libName, destName)
setCache(resolved)
return resolved
}
fun tarPath(context: Context): String? = resolve(context, "libtar_bin.so", "tar_bin", cacheTar)
fun zstdPath(context: Context): String? = resolve(context, "libzstd_bin.so", "zstd_bin", cacheZstd)
private fun resolve(context: Context, libName: String, destName: String, cache: ResolveCache): String? {
if (cache.initialized) return cache.path
private fun resolve(context: Context, libName: String, destName: String): String? {
val nativeLibDir = context.applicationInfo.nativeLibraryDir
val source = File(nativeLibDir, libName)
if (!source.isFile) {
Log.e(TAG, "$libName NOT FOUND at ${source.absolutePath}")
cache.initialized = true
cache.path = null
return null
}
val dest = File(context.filesDir, "bin/$destName")
@@ -40,10 +43,7 @@ object BinaryResolver {
source.inputStream().use { src -> dest.outputStream().use { out -> src.copyTo(out) } }
dest.setExecutable(true)
}
val result = dest.absolutePath
Log.i(TAG, "ready: $libName -> $result (${dest.length()} bytes) canExec=${dest.canExecute()}")
cache.path = result
cache.initialized = true
return result
Log.i(TAG, "ready: $libName -> ${dest.absolutePath} (${dest.length()} bytes) canExec=${dest.canExecute()}")
return dest.absolutePath
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,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

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

View File

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

View File

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

View File

@@ -0,0 +1,369 @@
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().
*/
class ResticRestBridge(
private val transport: RemoteTransport,
private val remoteBase: 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('/')
// 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 = path.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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package com.example.androidbackupgui.root
import android.util.Log
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
@@ -67,6 +68,7 @@ object RootShell {
suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult =
withContext(Dispatchers.IO) {
ensureActive()
try {
val result = withTimeout(timeoutMs) {
Shell.cmd(command).exec()
@@ -84,4 +86,17 @@ object RootShell {
ShellResult("", e.message ?: "Unknown error", -1)
}
}
/**
* 安全执行 root shell 命令,自动 shellEscape 每个参数。
* @param parts 命令和参数列表,第一个元素是命令本身
* @param timeoutMs 超时毫秒
*/
suspend fun execSafe(
parts: List<String>,
timeoutMs: Long = COMMAND_TIMEOUT_MS
): ShellResult = exec(
command = parts.joinToString(" ") { "'${it.shellEscape()}'" },
timeoutMs = timeoutMs
)
}

View File

@@ -3,6 +3,7 @@ package com.example.androidbackupgui.ui
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import androidx.core.content.ContextCompat
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
@@ -11,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
@@ -18,11 +20,18 @@ import com.example.androidbackupgui.backup.BackupService
import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.ResticWrapper
import com.example.androidbackupgui.backup.WifiManager
import com.example.androidbackupgui.backup.RemoteTransport
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.databinding.FragmentBackupBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import android.os.StatFs
import com.example.androidbackupgui.backup.StreamingBackup
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import com.example.androidbackupgui.backup.formatSize
import java.io.File
import java.util.Locale
@@ -38,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 }
@@ -53,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
@@ -69,7 +81,7 @@ class BackupFragment : Fragment() {
applySortFilter()
}
binding.selectAllButton.setOnClickListener {
selectedApps.addAll(apps.map { it.packageName })
selectedApps.addAll(apps.map { it.packageName.value })
applySortFilter()
}
binding.deselectAllButton.setOnClickListener {
@@ -87,24 +99,31 @@ class BackupFragment : Fragment() {
private fun loadUsers() {
viewLifecycleOwner.lifecycleScope.launch {
userList = AppScanner.enumerateUsers()
val names = userList.map { (id, name) -> "$name (ID: $id)" }
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.userSelector.adapter = adapter
binding.userSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedUserId = userList.getOrNull(position)?.first ?: 0
try {
userList = AppScanner.enumerateUsers()
val names = userList.map { (id, name) -> "$name (ID: $id)" }
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.userSelector.adapter = adapter
binding.userSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedUserId = userList.getOrNull(position)?.first ?: 0
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
} catch (e: Exception) {
binding.statusText.text = "加载用户失败: ${e.message}"
}
}
}
override fun onResume() {
super.onResume()
val configFile = File(requireContext().filesDir, "backup_settings.conf")
config = BackupConfig.fromFile(configFile)
if (::config.isInitialized) {
val configFile = File(requireContext().filesDir, "backup_settings.conf")
config = BackupConfig.fromFile(configFile)
updateOutputPathDisplay()
}
}
private fun scanApps() {
@@ -113,18 +132,24 @@ class BackupFragment : Fragment() {
binding.statusText.text = "正在扫描应用…"
viewLifecycleOwner.lifecycleScope.launch {
val ctx = requireContext()
val thirdParty = AppScanner.scanThirdParty(ctx, userId = selectedUserId)
val system = AppScanner.scanSystem(ctx, config, userId = selectedUserId)
apps = if (showSystemApps) thirdParty + system else thirdParty
selectedApps.clear()
selectedApps.addAll(apps.map { it.packageName })
try {
val ctx = requireContext()
val thirdParty = AppScanner.scanThirdParty(ctx, userId = selectedUserId)
val system = AppScanner.scanSystem(ctx, config, userId = selectedUserId)
apps = if (showSystemApps) thirdParty + system else thirdParty
selectedApps.clear()
selectedApps.addAll(apps.map { it.packageName.value })
binding.statusText.text = "共找到 ${apps.size} 个应用,全部已选中"
binding.backupButton.isEnabled = apps.isNotEmpty()
setRunning(false)
binding.statusText.text = "共找到 ${apps.size} 个应用,全部已选中"
binding.backupButton.isEnabled = apps.isNotEmpty()
setRunning(false)
applySortFilter()
applySortFilter()
} catch (e: Exception) {
binding.statusText.text = "扫描应用失败: ${e.message}"
setRunning(false)
binding.backupButton.isEnabled = false
}
}
}
@@ -138,18 +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 in selectedApps }
val toBackup = apps.filter { it.packageName.value in selectedApps }
if (toBackup.isEmpty()) return
setRunning(true)
binding.backupButton.isEnabled = false
binding.scanButton.isEnabled = false
@@ -159,7 +191,7 @@ class BackupFragment : Fragment() {
serviceIntent.action = BackupService.ACTION_START_BACKUP
serviceIntent.putExtra(BackupService.EXTRA_STATUS_TEXT, "正在备份 ${toBackup.size} 个应用…")
try {
requireContext().startForegroundService(serviceIntent)
ContextCompat.startForegroundService(requireContext(), serviceIntent)
} catch (_: Exception) {}
viewLifecycleOwner.lifecycleScope.launch {
@@ -167,21 +199,116 @@ class BackupFragment : Fragment() {
val outputDir = File(config.outputPath.ifEmpty {
requireContext().filesDir.absolutePath
})
WifiManager.backup(outputDir)
// ── 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 == progress.packageName }?.label
val label = allApps.find { it.packageName.value == progress.packageName }?.label
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
binding.statusText.text =
"[${progress.current}/${progress.total}] $name: ${progress.message}"
updateStatus("[${progress.current}/${progress.total}] $name: ${progress.message}")
}
)
// Store WiFi config inside Backup_* directory so restic/local restore can find it
WifiManager.backup(File(result.outputDir))
// If restic is enabled, snapshot to repository
var resticSummary: ResticWrapper.BackupSummary? = null
var resticError: String? = null
@@ -189,16 +316,16 @@ 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") {
if (!File(config.resticRepo, "config").exists()) {
binding.statusText.text = "restic 本地仓库未初始化,请先在设置中初始化"
updateStatus("restic 本地仓库未初始化,请先在设置中初始化")
return@launch
}
}
binding.statusText.text = "正在写入 restic 去重仓库…"
updateStatus("正在写入 restic 去重仓库…")
val resticResult = ResticWrapper.backup(
repoPath = config.resticRepo,
password = config.resticPassword,
@@ -210,61 +337,55 @@ class BackupFragment : Fragment() {
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onSyncProgress = { progress: RemoteTransport.TransferProgress ->
withContext(Dispatchers.Main) {
when (progress.phase) {
"list", "download", "upload", "delete_stale" ->
binding.statusText.text = "同步中: ${progress.current}/${progress.total} 个文件"
}
}
},
onByteSyncProgress = { progress ->
withContext(Dispatchers.Main) {
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
binding.progressBar.progress = progress.bytesTransferred.toInt()
binding.statusText.text = "同步中: ${progress.currentFile}\n" +
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}"
}
},
onProgress = { progress ->
if (progress.messageType == "status") {
binding.statusText.text = "去重仓库: %.0f%% (%d/%d 个文件)".format(
updateStatus("去重仓库: %.0f%% (%d/%d 个文件)".format(
progress.percentDone * 100,
progress.filesDone,
progress.totalFiles
)
))
}
}
)
resticResult.fold(
onSuccess = { resticSummary = it },
onFailure = { e ->
resticError = e.message
binding.statusText.text = "restic 快照失败: ${e.message}"
when (resticResult) {
is AppResult.Success -> resticSummary = resticResult.data
is AppResult.Failure -> {
resticError = resticResult.error.message
updateStatus("restic 快照失败: ${resticResult.error.message}")
}
)
}
}
}
binding.statusText.text = buildString {
updateStatus(buildString {
appendLine("备份完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
appendLine("耗时: ${result.elapsedMs / 1000}")
appendLine("输出: ${result.outputDir}")
if (resticSummary != null) {
appendLine("模式: 累积快照")
val summary = resticSummary
if (summary != null) {
appendLine()
appendLine("── Restic 快照 ──")
appendLine("ID: ${resticSummary!!.snapshotId.take(8)}")
appendLine("新增: ${resticSummary!!.dataAdded / 1024 / 1024} MB")
appendLine("文件: ${resticSummary!!.totalFilesProcessed}")
} else if (resticError != null) {
appendLine()
appendLine("── Restic 错误 ──")
appendLine(resticError!!)
appendLine("ID: ${summary.snapshotId.take(8)}")
appendLine("新增: ${summary.dataAdded / 1024 / 1024} MB")
appendLine("文件: ${summary.totalFilesProcessed}")
} else {
val err = resticError
if (err != null) {
appendLine()
appendLine("── Restic 错误 ──")
appendLine(err)
}
}
}
})
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
updateStatus("备份异常: ${e.message}")
} finally {
setRunning(false)
binding.backupButton.isEnabled = true
binding.scanButton.isEnabled = true
// Stop foreground service
try {
@@ -276,23 +397,151 @@ class BackupFragment : Fragment() {
}
}
private fun formatSize(bytes: Long): String {
if (bytes <= 0) return "0 B"
val units = arrayOf("B", "KB", "MB", "GB")
val digitGroups = (Math.log10(bytes.toDouble()) / Math.log10(1024.0)).toInt()
return String.format(Locale.US, "%.1f %s", bytes / Math.pow(1024.0, digitGroups.toDouble()), units[digitGroups])
}
private fun setRunning(running: Boolean) {
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
}
override fun onDestroyView() {
super.onDestroyView()
// Cleanup restic temp files when leaving the fragment
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
ResticWrapper.cleanup()
}
_binding = null
private suspend fun updateStatus(text: String) {
withContext(Dispatchers.Main) { binding.statusText.text = text }
}
private fun updateOutputPathDisplay() {
val path = config.outputPath.ifEmpty { requireContext().filesDir.absolutePath }
binding.outputPathLabel.text = path
}
private fun showOutputPathEditDialog() {
val editText = android.widget.EditText(requireContext()).apply {
setText(config.outputPath)
hint = requireContext().filesDir.absolutePath
}
com.google.android.material.dialog.MaterialAlertDialogBuilder(requireContext())
.setTitle("修改输出目录")
.setView(editText)
.setPositiveButton("确定") { _, _ ->
val newPath = editText.text.toString().trim()
config = config.copy(outputPath = newPath)
BackupConfig.toFile(config, File(requireContext().filesDir, "backup_settings.conf"))
updateOutputPathDisplay()
}
.setNegativeButton("取消", null)
.show()
}
// ── Space detection & streaming backup ────────────
/**
* Estimate the total size of data to back up using `du -sb`.
* Only counts data directories (not APKs) since that's the bulk.
*/
private suspend fun estimateBackupSize(apps: List<com.example.androidbackupgui.backup.AppInfo>): Long = withContext(Dispatchers.IO) {
var total = 0L
for (app in apps) {
val pkgEsc = app.packageName.value.shellEscape()
val result = RootShell.exec("du -sb /data/data/$pkgEsc 2>/dev/null | cut -f1")
val size = result.output.trim().toLongOrNull() ?: 0L
total += size
}
total
}
/**
* Check if [path] has at least [neededBytes] bytes free.
* Uses [StatFs] to query the filesystem.
*/
private fun hasEnoughSpace(path: File, neededBytes: Long): Boolean {
try {
val stat = StatFs(path.absolutePath)
val available = stat.availableBlocksLong * stat.blockSizeLong
// Require 1.5x headroom for temp files and metadata
return available >= neededBytes * 3 / 2
} catch (_: Exception) {
// If we can't check, assume enough space (staging mode)
return true
}
}
/**
* Run streaming backup via [StreamingBackup] + [ResticWrapper.backupStdin].
* Used when staging space is insufficient.
*/
@Suppress("UNUSED_PARAMETER")
private suspend fun runStreamingResticBackup(
config: com.example.androidbackupgui.backup.BackupConfig,
apps: List<com.example.androidbackupgui.backup.AppInfo>,
outputDir: File,
cacheDir: String
): ResticWrapper.BackupSummary? {
updateStatus("空间不足,启动流式备份模式…")
val cacheDirFile = File(cacheDir, "streaming_tmp")
cacheDirFile.mkdirs()
// Prepare streaming: create FIFO, metadata, collect APK paths
val streamingResult = StreamingBackup.prepareStreaming(
cacheDirFile, apps, null
)
// Start restic with stdin from FIFO, in parallel with data producer
var summary: ResticWrapper.BackupSummary? = null
var backupError: String? = null
coroutineScope {
// Launch restic backup (consumer)
val resticJob = async {
val result = ResticWrapper.backupStdin(
repoPath = config.resticRepo,
password = config.resticPassword,
stdinFile = streamingResult.dataFifo,
extraPaths = streamingResult.apkPaths + streamingResult.metaDir.absolutePath,
tags = listOf("streaming_${System.currentTimeMillis() / 1000}"),
hostname = "android-backup-gui",
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onProgress = { progress ->
if (progress.messageType == "status") {
updateStatus("流式去重仓库: %.0f%% (%d/%d 个文件)".format(
progress.percentDone * 100,
progress.filesDone,
progress.totalFiles
))
}
}
)
when (result) {
is AppResult.Success -> summary = result.data
is AppResult.Failure -> backupError = result.error.message
}
}
// Launch data producer (writes tar to FIFO)
val producerJob = async {
StreamingBackup.launchDataProducer(
apps = apps,
noDataBackup = excludeDataFromBackup.toSet(),
userId = selectedUserId.toString(),
fifoPath = streamingResult.dataFifo.absolutePath
)
}
// Wait for both to complete
producerJob.await()
resticJob.await()
}
// Cleanup FIFO
try { streamingResult.dataFifo.delete() } catch (_: Exception) {}
try { streamingResult.metaDir.deleteRecursively() } catch (_: Exception) {}
if (backupError != null) {
updateStatus("流式备份失败: $backupError")
}
return summary
}
}

View File

@@ -6,6 +6,8 @@ import android.view.View
import android.view.ViewGroup
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import android.util.Log
import com.google.android.material.snackbar.Snackbar
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
@@ -14,6 +16,9 @@ import com.example.androidbackupgui.R
import com.example.androidbackupgui.backup.BackupConfig
import com.example.androidbackupgui.databinding.FragmentConfigBinding
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.example.androidbackupgui.backup.ResticWrapper
class ConfigFragment : Fragment() {
@@ -66,10 +71,15 @@ class ConfigFragment : Fragment() {
// Initial async status check
refreshResticStatus()
// Observe ViewModel state for derived UI updates
// Observe ViewModel state and one-shot operation events
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.uiState.collect { state -> applyState(state) }
launch {
vm.uiState.collect { state -> applyState(state) }
}
launch {
vm.operationEvents.collect { event -> handleOperationEvent(event) }
}
}
}
}
@@ -133,6 +143,37 @@ class ConfigFragment : Fragment() {
}
}
// ── One-shot operation event handler ──────────────────────────────
/** Handle one-shot lifecycle events from ViewModel. */
private fun handleOperationEvent(event: OperationEvent) {
when (event) {
is OperationEvent.InitStarted -> Log.d(TAG, "init started")
is OperationEvent.InitCompleted -> {
Log.d(TAG, "init completed")
Snackbar.make(binding.root, "仓库初始化完成", Snackbar.LENGTH_SHORT).show()
}
is OperationEvent.InitFailed -> {
Log.d(TAG, "init failed")
Snackbar.make(binding.root, "仓库初始化失败", Snackbar.LENGTH_SHORT).show()
}
is OperationEvent.StatsStarted -> Log.d(TAG, "stats started")
is OperationEvent.StatsCompleted -> {
Log.d(TAG, "stats completed")
Snackbar.make(binding.root, "统计读取完成", Snackbar.LENGTH_SHORT).show()
}
is OperationEvent.PruneStarted -> Log.d(TAG, "prune started")
is OperationEvent.PruneFailed -> {
Log.d(TAG, "prune failed")
Snackbar.make(binding.root, "清理失败", Snackbar.LENGTH_SHORT).show()
}
is OperationEvent.PruneCompleted -> {
Log.d(TAG, "prune completed")
Snackbar.make(binding.root, "清理完成", Snackbar.LENGTH_SHORT).show()
}
}
}
// ── Form building helpers ────────────────────────────────────────
private fun readBackend(): String = when (binding.resticBackendGroup.checkedButtonId) {
@@ -156,25 +197,24 @@ class ConfigFragment : Fragment() {
// ── User actions ─────────────────────────────────────────────────
private fun saveConfig() {
vm.save(BackupConfig().also { config ->
config.backupMode = if (binding.backupModeSwitch.isChecked) 1 else 0
config.backupUserData = if (binding.backupUserDataSwitch.isChecked) 1 else 0
config.backupObbData = if (binding.backupObbSwitch.isChecked) 1 else 0
config.backgroundAppsIgnore = if (binding.ignoreRunningSwitch.isChecked) 1 else 0
config.outputPath = binding.outputPathEdit.text?.toString() ?: ""
config.compressionMethod = binding.compressionEdit.text?.toString()?.ifEmpty { "zstd" } ?: "zstd"
config.backupWifi = if (binding.backupWifiSwitch.isChecked) 1 else 0
config.resticEnabled = if (binding.resticEnabledSwitch.isChecked) 1 else 0
config.resticRepo = binding.resticRepoEdit.text?.toString()?.trim() ?: ""
config.resticPassword = binding.resticPasswordEdit.text?.toString() ?: ""
config.resticBackend = readBackend()
config.resticBackendUrl = binding.resticBackendUrlEdit.text?.toString()?.trim() ?: ""
config.resticBackendUser = binding.resticBackendUserEdit.text?.toString()?.trim() ?: ""
config.resticBackendPass = binding.resticBackendPassEdit.text?.toString() ?: ""
config.resticBackendShare = binding.resticBackendShareEdit.text?.toString()?.trim() ?: ""
config.resticBackendDomain = binding.resticBackendDomainEdit.text?.toString()?.trim() ?: ""
})
vm.save(BackupConfig(
backupMode = if (binding.backupModeSwitch.isChecked) 1 else 0,
backupUserData = if (binding.backupUserDataSwitch.isChecked) 1 else 0,
backupObbData = if (binding.backupObbSwitch.isChecked) 1 else 0,
backupWifi = if (binding.backupWifiSwitch.isChecked) 1 else 0,
backgroundAppsIgnore = if (binding.ignoreRunningSwitch.isChecked) 1 else 0,
outputPath = binding.outputPathEdit.text?.toString() ?: "",
compressionMethod = binding.compressionEdit.text?.toString()?.ifEmpty { "zstd" } ?: "zstd",
resticEnabled = if (binding.resticEnabledSwitch.isChecked) 1 else 0,
resticRepo = binding.resticRepoEdit.text?.toString()?.trim() ?: "",
resticPassword = binding.resticPasswordEdit.text?.toString() ?: "",
resticBackend = readBackend(),
resticBackendUrl = binding.resticBackendUrlEdit.text?.toString()?.trim() ?: "",
resticBackendUser = binding.resticBackendUserEdit.text?.toString()?.trim() ?: "",
resticBackendPass = binding.resticBackendPassEdit.text?.toString() ?: "",
resticBackendShare = binding.resticBackendShareEdit.text?.toString()?.trim() ?: "",
resticBackendDomain = binding.resticBackendDomainEdit.text?.toString()?.trim() ?: "",
))
}
private fun onFormChanged() {
@@ -207,9 +247,4 @@ class ConfigFragment : Fragment() {
vm.pruneResticSnapshots(readResticForm())
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
// cleanup is handled by ViewModel.onCleared()
}
}

View File

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

View File

@@ -1,6 +1,7 @@
package com.example.androidbackupgui.ui
import android.view.View
import android.util.TypedValue
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.LinearLayout
@@ -18,22 +19,26 @@ import com.google.android.material.color.MaterialColors
class PackageListAdapter(
private val apps: List<AppInfo>,
private val selected: Set<String>,
private val onToggle: (String, Boolean) -> Unit
private val onToggle: (String, Boolean) -> Unit,
private val excludeDataFrom: Set<String> = emptySet(),
private val onExcludeDataToggle: ((String, Boolean) -> Unit)? = null
) : RecyclerView.Adapter<PackageListAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val checkbox: CheckBox = view.findViewById(R.id.checkbox)
val textView: TextView = view.findViewById(R.id.appName)
val excludeToggle: TextView = view.findViewById(R.id.excludeToggle)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val ctx = parent.context
val res = ctx.resources
val card = MaterialCardView(ctx).apply {
layoutParams = ViewGroup.MarginLayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply { setMargins(0, 0, 0, 8) }
radius = 12f
).apply { setMargins(0, 0, 0, res.getDimensionPixelSize(R.dimen.card_margin_bottom)) }
radius = res.getDimension(R.dimen.card_radius)
cardElevation = 0f
strokeWidth = 0
setCardBackgroundColor(
@@ -42,32 +47,78 @@ class PackageListAdapter(
}
val layout = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(16, 12, 16, 12)
setPadding(res.getDimensionPixelSize(R.dimen.card_padding_horizontal), res.getDimensionPixelSize(R.dimen.card_padding_vertical), res.getDimensionPixelSize(R.dimen.card_padding_horizontal), res.getDimensionPixelSize(R.dimen.card_padding_vertical))
}
val cb = CheckBox(ctx).apply { id = R.id.checkbox }
val tv = TextView(ctx).apply {
id = R.id.appName
setPadding(16, 0, 0, 0)
textSize = 15f
setPadding(res.getDimensionPixelSize(R.dimen.card_padding_horizontal), 0, 0, 0)
setTextSize(TypedValue.COMPLEX_UNIT_PX, res.getDimension(R.dimen.list_item_text_size))
setTextColor(
MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, 0)
)
}
val et = TextView(ctx).apply {
id = R.id.excludeToggle
visibility = if (onExcludeDataToggle != null) View.VISIBLE else View.GONE
setPadding(res.getDimensionPixelSize(R.dimen.card_padding_horizontal), 0, 0, 0)
setTextSize(TypedValue.COMPLEX_UNIT_PX, res.getDimension(R.dimen.list_item_text_size) * 0.75f)
setTextColor(
MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurfaceVariant, 0)
)
}
layout.addView(cb)
layout.addView(tv)
layout.addView(et)
card.addView(layout)
return ViewHolder(card)
val holder = ViewHolder(card)
card.setOnClickListener {
val pos = holder.adapterPosition
if (pos == RecyclerView.NO_POSITION) return@setOnClickListener
val app = apps[pos]
val newChecked = !holder.checkbox.isChecked
// Temporarily suppress checkbox listener to avoid double-fire
holder.checkbox.setOnCheckedChangeListener(null)
holder.checkbox.isChecked = newChecked
holder.checkbox.setOnCheckedChangeListener { _, checked ->
onToggle(app.packageName.value, checked)
}
onToggle(app.packageName.value, newChecked)
}
return holder
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val app = apps[position]
val pkg = app.packageName.value
// Prefer app name (label), fall back to package name
holder.textView.text = app.label.ifEmpty { app.packageName }
holder.textView.text = app.label.ifEmpty { pkg }
// Avoid re-triggering listener during bind
holder.checkbox.setOnCheckedChangeListener(null)
holder.checkbox.isChecked = app.packageName in selected
holder.checkbox.isChecked = pkg in selected
holder.checkbox.setOnCheckedChangeListener { _, checked ->
onToggle(app.packageName, checked)
onToggle(pkg, checked)
}
// Configure per-app data exclusion toggle
val toggle = holder.excludeToggle
val dataToggleCb = onExcludeDataToggle
if (dataToggleCb != null) {
toggle.visibility = View.VISIBLE
val excluded = pkg in excludeDataFrom
toggle.text = "数据"
toggle.paintFlags = if (excluded) {
toggle.paintFlags or android.graphics.Paint.STRIKE_THRU_TEXT_FLAG
} else {
toggle.paintFlags and android.graphics.Paint.STRIKE_THRU_TEXT_FLAG.inv()
}
toggle.isSelected = excluded
toggle.setOnClickListener {
dataToggleCb(pkg, !excluded)
}
} else {
toggle.visibility = View.GONE
toggle.setOnClickListener(null)
}
}

View File

@@ -11,19 +11,18 @@ import android.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.androidbackupgui.backup.AppInfo
import com.example.androidbackupgui.backup.PackageName
import com.example.androidbackupgui.backup.AppScanner
import com.example.androidbackupgui.backup.BackupConfig
import com.example.androidbackupgui.backup.RestoreOperation
import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.ResticWrapper
import com.example.androidbackupgui.backup.WifiManager
import com.example.androidbackupgui.backup.RemoteTransport
import com.example.androidbackupgui.databinding.FragmentRestoreBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Locale
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
@@ -37,6 +36,7 @@ class RestoreFragment : Fragment() {
private var selectedPackages = mutableSetOf<String>()
private var resticConfig: BackupConfig? = null
private var selectedSnapshot: ResticWrapper.ResticSnapshot? = null
private var resticConfigFingerprint: String? = null
private var selectedUserId: Int = 0
private var userList: List<Pair<Int, String>> = listOf(0 to "Owner")
@@ -62,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
}
@@ -78,16 +78,20 @@ class RestoreFragment : Fragment() {
private fun loadUsers() {
viewLifecycleOwner.lifecycleScope.launch {
userList = AppScanner.enumerateUsers()
val names = userList.map { (id, name) -> "$name (ID: $id)" }
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.userSelector.adapter = adapter
binding.userSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedUserId = userList.getOrNull(position)?.first ?: 0
try {
userList = AppScanner.enumerateUsers()
val names = userList.map { (id, name) -> "$name (ID: $id)" }
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.userSelector.adapter = adapter
binding.userSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedUserId = userList.getOrNull(position)?.first ?: 0
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
} catch (e: Exception) {
binding.statusText.text = "加载用户失败: ${e.message}"
}
}
}
@@ -97,13 +101,34 @@ class RestoreFragment : Fragment() {
// Re-read config so changes from ConfigFragment take effect immediately
val configFile = File(requireContext().filesDir, "backup_settings.conf")
val config = BackupConfig.fromFile(configFile)
// Detect restic config change — clear stale state if repo/backend changed
val newFingerprint = "${config.resticRepo}|${config.resticBackend}|${config.resticBackendUrl}"
if (resticConfigFingerprint != null && resticConfigFingerprint != newFingerprint) {
selectedSnapshot = null
packages = emptyList()
selectedPackages.clear()
binding.backupDirText.text = ""
binding.restoreButton.isEnabled = false
binding.selectResticButton.visibility = View.GONE
}
resticConfigFingerprint = newFingerprint
resticConfig = if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) config else null
val binaryPath = ResticBinary.prepare(requireContext())
if (binaryPath != null && resticConfig != null) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
ResticWrapper.backendDomain = config.resticBackendDomain
// Skip redundant preparation if binary and backend config are already set
if (resticConfig != null &&
ResticWrapper.binaryPath.isNotEmpty() &&
ResticWrapper.binaryPath != "restic"
) {
binding.selectResticButton.visibility = View.VISIBLE
} else {
val binaryPath = ResticBinary.prepare(requireContext())
if (binaryPath != null && resticConfig != null) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.cacheDir = requireContext().cacheDir.absolutePath
ResticWrapper.backendDomain = config.resticBackendDomain
binding.selectResticButton.visibility = View.VISIBLE
}
}
}
@@ -143,7 +168,7 @@ class RestoreFragment : Fragment() {
binding.statusText.text = "${packages.size} 个备份应用"
binding.restoreButton.isEnabled = packages.isNotEmpty()
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = it) })
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = PackageName(it)) })
setupAppList()
}
@@ -153,79 +178,78 @@ class RestoreFragment : Fragment() {
binding.statusText.text = "正在同步远程仓库到本地…"
viewLifecycleOwner.lifecycleScope.launch {
val snapshotsResult = ResticWrapper.listSnapshots(
config.resticRepo, config.resticPassword,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onSyncProgress = { p ->
binding.statusText.text = "同步中: ${p.current}/${p.total} [${p.currentFile}]"
},
onByteSyncProgress = { bp ->
binding.statusText.text = "下载中: ${bp.bytesTransferred / 1024 / 1024} MB / ${bp.totalBytes / 1024 / 1024} MB"
}
)
if (snapshotsResult.isFailure) {
binding.statusText.text = "读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}"
setRunning(false)
return@launch
}
val snapshots = snapshotsResult.getOrThrow()
if (snapshots.isEmpty()) {
binding.statusText.text = "没有可用的 restic 快照"
setRunning(false)
return@launch
}
// 多快照时让用户选择,单个快照自动选
val chosenSnapshot = if (snapshots.size == 1) {
snapshots.first()
} else {
pickSnapshot(snapshots) ?: run {
binding.statusText.text = "已取消选择"
try {
val snapshotsResult = ResticWrapper.listSnapshots(
config.resticRepo, config.resticPassword,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
)
if (snapshotsResult.isFailure) {
updateStatus("读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}")
setRunning(false)
return@launch
}
}
// Switch to restic source
backupDir = null
selectedSnapshot = chosenSnapshot
val backupPath = selectedSnapshot!!.paths.firstOrNull() ?: run {
binding.statusText.text = "快照中找不到备份路径"
val snapshots = snapshotsResult.getOrThrow()
if (snapshots.isEmpty()) {
updateStatus("没有可用的 restic 快照")
setRunning(false)
return@launch
}
// 多快照时让用户选择,单个快照自动选
val chosenSnapshot = if (snapshots.size == 1) {
snapshots.first()
} else {
pickSnapshot(snapshots) ?: run {
updateStatus("已取消选择")
setRunning(false)
return@launch
}
}
// Switch to restic source
backupDir = null
selectedSnapshot = chosenSnapshot
val backupPath = selectedSnapshot!!.paths.firstOrNull() ?: run {
updateStatus("快照中找不到备份路径")
setRunning(false)
return@launch
}
// Read app list from the snapshot
val appListContent = readResticFile(config, selectedSnapshot!!.id, "$backupPath/appList.txt")
packages = if (appListContent != null) {
appListContent.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
} else {
emptyList()
}
if (packages.isEmpty()) {
updateStatus("无法从快照读取应用列表")
setRunning(false)
return@launch
}
binding.backupDirText.text = "restic: ${selectedSnapshot!!.time.take(19)} (${snapshots.size} 个快照可用)"
selectedPackages.clear()
selectedPackages.addAll(packages)
// Resolve app labels for display
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = PackageName(it)) })
updateStatus("restic 快照共 ${packages.size} 个应用,点击恢复开始")
binding.restoreButton.isEnabled = true
setRunning(false)
return@launch
}
// Read app list from the snapshot
val appListContent = readResticFile(config, selectedSnapshot!!.id, "$backupPath/appList.txt")
packages = if (appListContent != null) {
appListContent.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
} else {
emptyList()
}
if (packages.isEmpty()) {
binding.statusText.text = "无法从快照读取应用列表"
setupAppList()
} catch (e: Exception) {
binding.statusText.text = "选择快照失败: ${e.message}"
setRunning(false)
return@launch
}
binding.backupDirText.text = "restic: ${selectedSnapshot!!.time.take(19)} (${snapshots.size} 个快照可用)"
selectedPackages.clear()
selectedPackages.addAll(packages)
// Resolve app labels for display
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = it) })
binding.statusText.text = "restic 快照共 ${packages.size} 个应用,点击恢复开始"
binding.restoreButton.isEnabled = true
setRunning(false)
setupAppList()
}
}
@@ -259,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() {
@@ -274,113 +301,104 @@ class RestoreFragment : Fragment() {
binding.selectDirButton.isEnabled = false
viewLifecycleOwner.lifecycleScope.launch {
val result = if (selectedSnapshot != null && resticConfig != null) {
// Restic restore
val snapshot = selectedSnapshot!!
val config = resticConfig!!
val backupPath = snapshot.paths.firstOrNull() ?: return@launch
val staging = File(requireContext().cacheDir, "restic_restore_${snapshot.shortId}")
staging.mkdirs()
try {
binding.statusText.text = "正在从 restic 快照恢复到暂存目录…"
val restoreResult = ResticWrapper.restore(
repoPath = config.resticRepo,
password = config.resticPassword,
snapshotId = snapshot.id,
targetPath = staging.absolutePath,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onSyncProgress = { progress: RemoteTransport.TransferProgress ->
withContext(Dispatchers.Main) {
when (progress.phase) {
"list", "download", "upload", "delete_stale" ->
binding.statusText.text = "同步中: ${progress.current}/${progress.total} 个文件"
}
}
},
onByteSyncProgress = { progress ->
withContext(Dispatchers.Main) {
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
binding.progressBar.progress = progress.bytesTransferred.toInt()
binding.statusText.text = "同步中: ${progress.currentFile}\n" +
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}"
}
},
onProgress = { msg -> binding.statusText.text = msg }
)
try {
val result = if (selectedSnapshot != null && resticConfig != null) {
// Restic restore
val snapshot = selectedSnapshot ?: return@launch
val config = resticConfig ?: return@launch
val backupPath = snapshot.paths.firstOrNull() ?: return@launch
val staging = File(requireContext().cacheDir, "restic_restore_${snapshot.shortId}")
staging.mkdirs()
try {
binding.progressBar.isIndeterminate = true
if (restoreResult.isFailure) {
binding.statusText.text = "restic 恢复失败: ${restoreResult.exceptionOrNull()?.message}"
setRunning(false)
binding.selectDirButton.isEnabled = true
return@launch
binding.statusText.text = "正在从 restic 快照恢复到暂存目录…"
val restoreResult = ResticWrapper.restore(
repoPath = config.resticRepo,
password = config.resticPassword,
snapshotId = snapshot.id,
targetPath = staging.absolutePath,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onProgress = { msg -> withContext(Dispatchers.Main) { binding.statusText.text = msg } }
)
if (restoreResult.isFailure) {
updateStatus("restic 恢复失败: ${restoreResult.exceptionOrNull()?.message}")
return@launch
}
// The restored backup directory: <staging>/<original_absolute_path>
val restoredBackupDir = File(staging, backupPath.removePrefix("/"))
updateStatus("正在从恢复的备份安装应用…")
val r = RestoreOperation.restoreApps(
context = requireContext(),
backupDir = restoredBackupDir,
userId = selectedUserId.toString(),
filterPkgs = selectedPackages,
onProgress = { progress ->
val label = appInfos.find { it.packageName.value == progress.packageName }?.label
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
binding.statusText.text =
"[${progress.current}/${progress.total}] $name: ${progress.message}"
}
)
// Also restore WiFi if backup exists
WifiManager.restore(restoredBackupDir)
r
} finally {
try { staging.deleteRecursively() } catch (_: Exception) {}
}
// The restored backup directory: <staging>/<original_absolute_path>
val restoredBackupDir = File(staging, backupPath.removePrefix("/"))
binding.statusText.text = "正在从恢复的备份安装应用…"
} else {
// Local restore
val dir = backupDir ?: return@launch
val r = RestoreOperation.restoreApps(
backupDir = restoredBackupDir,
context = requireContext(),
backupDir = dir,
userId = selectedUserId.toString(),
filterPkgs = selectedPackages,
onProgress = { progress ->
val label = appInfos.find { it.packageName == progress.packageName }?.label
val label = appInfos.find { it.packageName.value == progress.packageName }?.label
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
binding.statusText.text =
"[${progress.current}/${progress.total}] $name: ${progress.message}"
}
)
// Also restore WiFi if backup exists
WifiManager.restore(restoredBackupDir)
// Also restore WiFi if backup exists locally
WifiManager.restore(dir)
r
} finally {
try { staging.deleteRecursively() } catch (_: Exception) {}
}
} else {
// Local restore
val dir = backupDir ?: return@launch
val r = RestoreOperation.restoreApps(
backupDir = dir,
userId = selectedUserId.toString(),
filterPkgs = selectedPackages,
onProgress = { progress ->
val label = appInfos.find { it.packageName == progress.packageName }?.label
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
binding.statusText.text =
"[${progress.current}/${progress.total}] $name: ${progress.message}"
}
)
// Also restore WiFi if backup exists locally
WifiManager.restore(dir)
r
}
binding.statusText.text = buildString {
appendLine("恢复完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
appendLine("耗时: ${result.elapsedMs / 1000}")
appendLine("如有 SSAID请立即重启设备后再开启应用")
binding.statusText.text = buildString {
appendLine("恢复完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
appendLine("耗时: ${result.elapsedMs / 1000}")
appendLine("如有 SSAID请立即重启设备后再开启应用")
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
binding.statusText.text = "恢复异常: ${e.message}"
} finally {
setRunning(false)
binding.selectDirButton.isEnabled = true
}
setRunning(false)
binding.selectDirButton.isEnabled = true
}
}
private fun formatSize(bytes: Long): String {
if (bytes <= 0) return "0 B"
val units = arrayOf("B", "KB", "MB", "GB")
val digitGroups = (Math.log10(bytes.toDouble()) / Math.log10(1024.0)).toInt()
return String.format(Locale.US, "%.1f %s", bytes / Math.pow(1024.0, digitGroups.toDouble()), units[digitGroups])
}
private fun setRunning(running: Boolean) {
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
}
private suspend fun updateStatus(text: String) {
binding.statusText.text = text
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null

View File

@@ -4,7 +4,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
android:padding="@dimen/fragment_horizontal_padding"
android:background="?attr/colorSurface">
<LinearLayout
@@ -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"
@@ -62,9 +95,9 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:layout_marginEnd="2dp"
android:text="A-Z"
android:textSize="12sp"
android:textSize="11sp"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
@@ -72,9 +105,10 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:text="大小"
android:textSize="12sp"
android:textSize="11sp"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
@@ -82,9 +116,10 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:text="全选"
android:textSize="12sp"
android:textSize="11sp"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
@@ -92,9 +127,9 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:layout_marginStart="2dp"
android:text="取消全选"
android:textSize="12sp"
android:textSize="11sp"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
@@ -129,6 +164,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:maxLines="3"
android:ellipsize="end"
android:text="点击扫描以载入应用列表"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />

View File

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

View File

@@ -4,7 +4,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
android:padding="@dimen/fragment_horizontal_padding"
android:background="?attr/colorSurface">
<LinearLayout
@@ -85,6 +85,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:maxLines="3"
android:ellipsize="end"
android:text="请先选择备份文件夹"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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