Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32182b592e | ||
|
|
bb7dc9a700 | ||
|
|
b01569416d | ||
|
|
26823fcb6f | ||
|
|
6f6549d897 | ||
|
|
c10505fc10 | ||
|
|
7e98e0f78e | ||
|
|
922a8f0381 | ||
|
|
5fcf261025 | ||
|
|
14b914252e | ||
|
|
c01428b866 | ||
|
|
51fe8e22c0 | ||
|
|
f5dd61a83b | ||
|
|
40f03e5bad | ||
|
|
45f7af00b8 | ||
|
|
7ef0b2c9da | ||
|
|
6fa15af565 | ||
|
|
6cdad04905 | ||
|
|
5cbd21577b | ||
|
|
1bae01de72 | ||
|
|
e710c36ee2 | ||
|
|
c1bbef4eef | ||
|
|
4c4542e059 | ||
|
|
ef78ab8bec | ||
|
|
a38a483c70 | ||
|
|
d0bfef41c8 | ||
|
|
0bde3b0a75 | ||
|
|
d2ea9f532f | ||
|
|
ac0fd8b063 | ||
|
|
fde6d05b83 | ||
|
|
2ff096ee8a | ||
|
|
eae7f4b369 | ||
|
|
420d960ce1 | ||
|
|
88e81e956c | ||
|
|
c12f7a9c81 | ||
|
|
a43638698c | ||
|
|
12fe29f841 | ||
|
|
b7addcca6b | ||
|
|
98d4029fd4 | ||
|
|
07366f744f | ||
|
|
88e18f4c57 | ||
|
|
d8992c3931 | ||
|
|
c7e9d8b5f1 | ||
|
|
3e1f8a5937 | ||
|
|
80b84f6cff | ||
|
|
0b017e853b | ||
|
|
2351cce99e |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -16,3 +16,10 @@ Thumbs.db
|
||||
# Keystore (regenerate if needed)
|
||||
debug.keystore
|
||||
release.keystore
|
||||
|
||||
# Memory files from agent harness
|
||||
memory:*
|
||||
|
||||
# Restic test repository (contains encryption keys)
|
||||
test/
|
||||
kmboxnet
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **android-backup-gui** (933 symbols, 2388 relationships, 80 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **android-backup-gui** (1295 symbols, 3535 relationships, 112 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **android-backup-gui** (933 symbols, 2388 relationships, 80 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **android-backup-gui** (1295 symbols, 3535 relationships, 112 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
18
README.md
18
README.md
@@ -9,6 +9,7 @@ Android 应用备份与恢复工具,集成 [restic](https://restic.net/) 实
|
||||
- **并行备份/恢复** — 备份并发数 3(Semaphore(3)),恢复并发数 2(Semaphore(2))
|
||||
- **存档完整性校验** — 备份后自动 zstd/gzip 校验数据归档
|
||||
- **restic 增量去重** — 内建 `librestic.so`(~24MB),支持本地和远端仓库
|
||||
- **构建体积优化** — Release APK 仅 11.8 MB(ProGuard/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 APK(ProGuard/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/` 下。
|
||||
|
||||
## 使用说明
|
||||
|
||||
|
||||
@@ -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 8
|
||||
versionName "1.7"
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
@@ -20,17 +37,25 @@ android {
|
||||
}
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile file("release.keystore")
|
||||
storeFile rootProject.file("app/release.keystore")
|
||||
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "android"
|
||||
keyAlias "release"
|
||||
keyPassword System.getenv("KEY_PASSWORD") ?: "android"
|
||||
v1SigningEnabled true
|
||||
v2SigningEnabled true
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
signingConfig signingConfigs.release
|
||||
if (rootProject.file("app/release.keystore").exists()) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
testOptions {
|
||||
unitTests.all {
|
||||
|
||||
useJUnitPlatform()
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
@@ -44,6 +69,13 @@ android {
|
||||
jniLibs {
|
||||
useLegacyPackaging true
|
||||
}
|
||||
resources {
|
||||
excludes += [
|
||||
'org/bouncycastle/pqc/crypto/picnic/lowmcL5.bin.properties',
|
||||
'org/bouncycastle/pqc/crypto/picnic/lowmcL3.bin.properties',
|
||||
'org/bouncycastle/pqc/crypto/picnic/lowmcL1.bin.properties',
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +93,23 @@ dependencies {
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
|
||||
|
||||
// 方案A: jcifs-ng (SMB) + sardine-android (WebDAV) 替代 rclone serve
|
||||
implementation "eu.agno3.jcifs:jcifs-ng:2.1.10"
|
||||
implementation "com.github.thegrizzlylabs:sardine-android:v0.9"
|
||||
implementation("eu.agno3.jcifs:jcifs-ng:2.1.10") {
|
||||
exclude group: 'org.bouncycastle'
|
||||
}
|
||||
implementation("com.github.thegrizzlylabs:sardine-android:v0.9") {
|
||||
exclude group: 'xpp3'
|
||||
exclude group: 'stax'
|
||||
}
|
||||
implementation "org.slf4j:slf4j-android:1.7.36"
|
||||
|
||||
// root shell via libsu (Magisk/KernelSU/APatch)
|
||||
implementation 'com.github.topjohnwu:libsu:6.0.0'
|
||||
// Full BouncyCastle provider (includes MD4 required by jcifs-ng SMB)
|
||||
implementation 'org.bouncycastle:bcprov-jdk15to18:1.77'
|
||||
implementation 'org.nanohttpd:nanohttpd:2.3.1'
|
||||
testImplementation "io.kotest:kotest-runner-junit5:5.9.1"
|
||||
testImplementation "io.kotest:kotest-assertions-core:5.9.1"
|
||||
testImplementation "io.kotest:kotest-property:5.9.1"
|
||||
testImplementation "io.mockk:mockk:1.13.12"
|
||||
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
|
||||
}
|
||||
|
||||
59
app/proguard-rules.pro
vendored
59
app/proguard-rules.pro
vendored
@@ -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 { *; }
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -22,6 +25,11 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".backup.BackupService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -12,12 +12,14 @@ import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.example.androidbackupgui.databinding.ActivityMainBinding
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.backup.LogUtil
|
||||
import com.example.androidbackupgui.ui.BackupFragment
|
||||
import com.example.androidbackupgui.ui.ConfigFragment
|
||||
import com.example.androidbackupgui.ui.RestoreFragment
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
@@ -34,15 +36,47 @@ class MainActivity : AppCompatActivity() {
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
// Configure libsu with global mount namespace support
|
||||
RootShell.configure()
|
||||
|
||||
// Request root access on startup
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
RootShell.ensureSession()
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
RootShell.ensureSession()
|
||||
}
|
||||
// Initialize file-based logging
|
||||
LogUtil.init(filesDir)
|
||||
}
|
||||
|
||||
// Edge-to-edge: pad toolbar below status bar
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.topAppBar) { view, insets ->
|
||||
// Edge-to-edge: distribute system bar insets (status bar, nav bar, cutout) to children
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets ->
|
||||
val statusBars = insets.getInsets(WindowInsetsCompat.Type.statusBars())
|
||||
view.setPadding(view.paddingLeft, statusBars.top, view.paddingRight, view.paddingBottom)
|
||||
val navBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
|
||||
|
||||
// Pad toolbar below status bar (preserve existing horizontal padding)
|
||||
binding.topAppBar.setPadding(
|
||||
binding.topAppBar.paddingLeft,
|
||||
statusBars.top,
|
||||
binding.topAppBar.paddingRight,
|
||||
binding.topAppBar.paddingBottom
|
||||
)
|
||||
|
||||
// Pad bottom nav above navigation bar so menu items are visible
|
||||
binding.bottomNav.setPadding(
|
||||
binding.bottomNav.paddingLeft,
|
||||
binding.bottomNav.paddingTop,
|
||||
binding.bottomNav.paddingRight,
|
||||
navBars.bottom
|
||||
)
|
||||
|
||||
// Pad view pager above navigation bar so fragment content doesn't overlap nav bar
|
||||
binding.viewPager.setPadding(
|
||||
binding.viewPager.paddingLeft,
|
||||
binding.viewPager.paddingTop,
|
||||
binding.viewPager.paddingRight,
|
||||
navBars.bottom
|
||||
)
|
||||
|
||||
insets
|
||||
}
|
||||
|
||||
@@ -54,6 +88,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
binding.viewPager.adapter = TabAdapter(this, fragments)
|
||||
binding.viewPager.isUserInputEnabled = true
|
||||
binding.viewPager.offscreenPageLimit = 2
|
||||
|
||||
binding.bottomNav.setOnItemSelectedListener { item ->
|
||||
when (item.itemId) {
|
||||
|
||||
@@ -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)
|
||||
@@ -7,36 +7,49 @@ import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
data class DataSizes(
|
||||
val apkBytes: Long = 0,
|
||||
val userBytes: Long = 0,
|
||||
val userDeBytes: Long = 0,
|
||||
val dataBytes: Long = 0,
|
||||
val obbBytes: Long = 0,
|
||||
val mediaBytes: Long = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AppInfo(
|
||||
val packageName: String,
|
||||
var label: String = "",
|
||||
val packageName: PackageName,
|
||||
val label: String = "",
|
||||
val isSystem: Boolean = false,
|
||||
val apkPaths: List<String> = emptyList(),
|
||||
val hasObb: Boolean = false,
|
||||
val isRunning: Boolean = false,
|
||||
val backupSize: Long = 0 // estimated from last backup
|
||||
val backupSize: Long = 0, // estimated from last backup
|
||||
// Enhanced fields (multi-user, keystore, icon)
|
||||
val userId: UserId = UserId(0),
|
||||
val hasKeystore: Boolean = false,
|
||||
val iconPath: String? = null,
|
||||
val dataSizes: DataSizes = DataSizes(),
|
||||
)
|
||||
|
||||
object AppScanner {
|
||||
|
||||
/** Scan all third-party installed packages. */
|
||||
suspend fun scanThirdParty(context: Context): List<AppInfo> = withContext(Dispatchers.IO) {
|
||||
val result = RootShell.exec("pm list packages -3")
|
||||
suspend fun scanThirdParty(context: Context, userId: Int = 0): List<AppInfo> = withContext(Dispatchers.IO) {
|
||||
val result = RootShell.exec("pm list packages -3 --user $userId")
|
||||
if (!result.isSuccess) return@withContext emptyList()
|
||||
|
||||
val packages = result.output.lines()
|
||||
.filter { it.startsWith("package:") }
|
||||
.map { it.removePrefix("package:").trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.map { AppInfo(packageName = it) }
|
||||
.map { AppInfo(packageName = PackageName(it), userId = UserId(userId)) }
|
||||
resolveLabels(context, packages)
|
||||
}
|
||||
|
||||
/** Scan all system packages. */
|
||||
suspend fun scanSystem(context: Context, config: BackupConfig): List<AppInfo> = withContext(Dispatchers.IO) {
|
||||
val result = RootShell.exec("pm list packages -s")
|
||||
suspend fun scanSystem(context: Context, config: BackupConfig, userId: Int = 0): List<AppInfo> = withContext(Dispatchers.IO) {
|
||||
val result = RootShell.exec("pm list packages -s --user $userId")
|
||||
if (!result.isSuccess) return@withContext emptyList()
|
||||
|
||||
val systemWhitelist = config.system.toSet()
|
||||
@@ -48,14 +61,12 @@ object AppScanner {
|
||||
.map { it.removePrefix("package:").trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.filter { pkg ->
|
||||
// Allow if in system whitelist or data whitelist
|
||||
pkg in systemWhitelist || pkg in dataWhitelist
|
||||
}
|
||||
.filter { pkg ->
|
||||
// Exclude if in blacklist (when blacklistMode=1, full ignore)
|
||||
if (config.blacklistMode == 1) pkg !in blacklist else true
|
||||
}
|
||||
.map { AppInfo(packageName = it, isSystem = true) }
|
||||
.map { AppInfo(packageName = PackageName(it), isSystem = true, userId = UserId(userId)) }
|
||||
resolveLabels(context, packages)
|
||||
}
|
||||
|
||||
@@ -68,15 +79,15 @@ object AppScanner {
|
||||
fun resolveLabels(context: Context, packages: List<AppInfo>): List<AppInfo> {
|
||||
if (packages.isEmpty()) return packages
|
||||
val pm = context.packageManager
|
||||
for (app in packages) {
|
||||
app.label = try {
|
||||
val ai = pm.getApplicationInfo(app.packageName, 0)
|
||||
return packages.map { app ->
|
||||
val resolvedLabel = try {
|
||||
val ai = pm.getApplicationInfo(app.packageName.value, 0)
|
||||
pm.getApplicationLabel(ai).toString()
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
app.packageName
|
||||
app.packageName.value
|
||||
}
|
||||
app.copy(label = resolvedLabel)
|
||||
}
|
||||
return packages
|
||||
}
|
||||
|
||||
/** Get APK paths for a package. */
|
||||
@@ -112,7 +123,69 @@ object AppScanner {
|
||||
val result = RootShell.exec("pidof '${packageName.shellEscape()}'")
|
||||
result.output.isNotBlank()
|
||||
}
|
||||
/** Check if an app has keystore entries (critical — keystore keys can be lost on backup). */
|
||||
suspend fun hasKeystore(packageName: String): Boolean = withContext(Dispatchers.IO) {
|
||||
// Resolve the app's UID first
|
||||
val uidResult = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep 'userId=' | head -1")
|
||||
val uid = uidResult.output
|
||||
.substringAfter("userId=", "")
|
||||
.substringBefore(" ")
|
||||
.substringBefore(",")
|
||||
.trim()
|
||||
.toIntOrNull() ?: return@withContext false
|
||||
// keystore_cli_v2 list as app UID — more than 1 line means has keystore entries
|
||||
val ksResult = RootShell.exec("su $uid -c 'keystore_cli_v2 list' 2>/dev/null")
|
||||
ksResult.output.lines().count { it.isNotBlank() } > 1
|
||||
}
|
||||
/** Enumerate all user profiles on the device for multi-user support. */
|
||||
suspend fun enumerateUsers(): List<Pair<Int, String>> = withContext(Dispatchers.IO) {
|
||||
val result = RootShell.exec("pm list users")
|
||||
if (!result.isSuccess) return@withContext listOf(0 to "Owner")
|
||||
|
||||
result.output.lines()
|
||||
.filter { it.contains("UserInfo") }
|
||||
.mapNotNull { line ->
|
||||
val id = line.substringBefore(":").trim().toIntOrNull()
|
||||
val name = line.substringAfter(":").substringBefore(":").trim()
|
||||
if (id != null) id to name else null
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract and save an app's icon to the given directory. */
|
||||
suspend fun extractIcon(packageName: String, destDir: java.io.File, userId: Int = 0): String? = withContext(Dispatchers.IO) {
|
||||
// Try snapshot cache first
|
||||
val snapshotDir = "/data/system_ce/$userId/snapshots/$packageName"
|
||||
val snapshotResult = RootShell.exec("ls '${snapshotDir.shellEscape()}/' 2>/dev/null | head -1")
|
||||
if (snapshotResult.isSuccess && snapshotResult.output.isNotBlank()) {
|
||||
val iconName = snapshotResult.output.trim()
|
||||
val iconFile = java.io.File(destDir, "app_icon.png")
|
||||
val copyResult = RootShell.exec("cp '${snapshotDir.shellEscape()}/${iconName.shellEscape()}' '${iconFile.absolutePath.shellEscape()}' 2>/dev/null")
|
||||
if (copyResult.isSuccess && iconFile.exists()) {
|
||||
return@withContext iconFile.absolutePath
|
||||
}
|
||||
}
|
||||
// Fallback: extract from APK using aapt
|
||||
val apkPaths = getApkPaths(packageName)
|
||||
if (apkPaths.isNotEmpty()) {
|
||||
val primaryApk = apkPaths.first()
|
||||
val badgeResult = RootShell.exec("aapt d badging '$primaryApk' 2>/dev/null | grep '^application:.*icon=' | head -1")
|
||||
if (badgeResult.isSuccess) {
|
||||
val iconPath = badgeResult.output
|
||||
.substringAfter("icon='")
|
||||
.substringBefore("'")
|
||||
.takeIf { it.isNotBlank() }
|
||||
if (iconPath != null) {
|
||||
// The icon path is relative inside the APK, extract using aapt
|
||||
val iconFile = java.io.File(destDir, "app_icon.png")
|
||||
RootShell.exec("aapt d raw '$primaryApk' '$iconPath' > '${iconFile.absolutePath.shellEscape()}' 2>/dev/null")
|
||||
if (iconFile.exists()) {
|
||||
return@withContext iconFile.absolutePath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
/** Apply appList.txt-style filters. Lines starting with # are ignored, ! means apk-only. */
|
||||
fun parseAppList(content: String): List<Pair<String, Boolean>> {
|
||||
return content.lines()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import com.example.androidbackupgui.backup.ResticWrapper.SnapshotAppInfo
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import android.util.Log
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
@@ -48,13 +50,21 @@ object BackupOperation {
|
||||
* @param config backup configuration
|
||||
* @param outputDir root output directory
|
||||
* @param userId Android user ID (0, 999, etc.)
|
||||
* @param onProgress callback for UI updates
|
||||
* @param includePkgs if non-empty, only backup apps whose package name is in this set;
|
||||
* metadata (app_details.json, appList.txt) is still generated for all [apps].
|
||||
* @param legacyApps metadata from a previous snapshot used to populate app_details.json
|
||||
* for apps not in [apps] (keeps them in the cumulative snapshot record
|
||||
* without requiring re-scans of possibly-uninstalled apps).
|
||||
*/
|
||||
suspend fun backupApps(
|
||||
context: android.content.Context,
|
||||
apps: List<AppInfo>,
|
||||
config: BackupConfig,
|
||||
outputDir: File,
|
||||
userId: String = "0",
|
||||
noDataBackup: Set<String> = emptySet(),
|
||||
includePkgs: Set<String> = emptySet(),
|
||||
legacyApps: Map<String, SnapshotAppInfo>? = null,
|
||||
onProgress: suspend (BackupProgress) -> Unit = {}
|
||||
): BackupResult = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (BackupProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
@@ -63,32 +73,36 @@ object BackupOperation {
|
||||
// Create backup structure
|
||||
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
|
||||
backupRoot.mkdirs()
|
||||
LogUtil.i(TAG, "backupApps: starting backup of ${apps.size} apps to ${backupRoot.absolutePath}")
|
||||
|
||||
// Write app list
|
||||
// Write app list — includes ALL packages in [apps] (selected + legacy from snapshot)
|
||||
val appListFile = File(backupRoot, "appList.txt")
|
||||
appListFile.writeText(apps.joinToString("\n") { it.packageName })
|
||||
appListFile.writeText(apps.joinToString("\n") { it.packageName.value })
|
||||
|
||||
// Write metadata JSON
|
||||
// Write metadata JSON — fresh metadata for selected apps, legacy for historical apps
|
||||
val metaFile = File(backupRoot, "app_details.json")
|
||||
metaFile.writeText(buildAppDetailsJson(apps))
|
||||
metaFile.writeText(buildAppDetailsJson(apps, legacyApps))
|
||||
|
||||
val backupTargets = if (includePkgs.isEmpty()) apps else apps.filter { it.packageName.value in includePkgs }
|
||||
val totalCount = backupTargets.size
|
||||
LogUtil.i(TAG, "backupApps: includePkgs=${includePkgs.size} targets=$totalCount")
|
||||
val semaphore = Semaphore(3)
|
||||
val successAtomic = AtomicInteger(0)
|
||||
val failAtomic = AtomicInteger(0)
|
||||
val skippedAtomic = AtomicInteger(0)
|
||||
|
||||
coroutineScope {
|
||||
apps.forEachIndexed { index, app ->
|
||||
launch {
|
||||
if (!coroutineContext.isActive) return@launch
|
||||
backupTargets.mapIndexed { index, app ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
val appDir = File(backupRoot, app.packageName)
|
||||
ensureActive()
|
||||
val appDir = File(backupRoot, app.packageName.value)
|
||||
appDir.mkdirs()
|
||||
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "apk", "正在备份 APK…"))
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "apk", "正在备份 APK…"))
|
||||
|
||||
// 1. Backup APK
|
||||
val paths = AppScanner.getApkPaths(app.packageName)
|
||||
val paths = AppScanner.getApkPaths(app.packageName.value)
|
||||
val apkOk = if (paths.isNotEmpty()) {
|
||||
paths.withIndex().all { (i, apkPath) ->
|
||||
val destName = if (paths.size > 1) "${app.packageName}_split_$i.apk" else "${app.packageName}.apk"
|
||||
@@ -98,54 +112,76 @@ object BackupOperation {
|
||||
|
||||
if (!apkOk) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "APK 备份失败"))
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "APK 备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
|
||||
// 1.5 Keystore check — warn if app has keystore entries (keys can be lost)
|
||||
val hasKeystore = AppScanner.hasKeystore(app.packageName.value)
|
||||
if (hasKeystore) {
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失"))
|
||||
}
|
||||
|
||||
// 2. Backup user data (if configured)
|
||||
if (config.backupMode == 1 && config.backupUserData == 1) {
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "data", "正在备份数据…"))
|
||||
if (!backupUserData(app.packageName, appDir, userId, config.compressionMethod)) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "数据备份失败"))
|
||||
return@withPermit
|
||||
if (app.packageName.value in noDataBackup) {
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "跳过数据备份(已排除)"))
|
||||
} else {
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "正在备份数据…"))
|
||||
if (!backupUserData(context, app.packageName.value, appDir, userId, config.compressionMethod)) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "数据备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Backup OBB (if configured and exists)
|
||||
if (config.backupMode == 1 && config.backupObbData == 1) {
|
||||
val hasObb = AppScanner.hasObbData(app.packageName)
|
||||
val hasObb = AppScanner.hasObbData(app.packageName.value)
|
||||
if (hasObb) {
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "obb", "正在备份 OBB…"))
|
||||
if (!backupObb(app.packageName, appDir, config.compressionMethod)) {
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "obb", "正在备份 OBB…"))
|
||||
if (!backupObb(app.packageName.value, appDir, config.compressionMethod)) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "OBB 备份失败"))
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "OBB 备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Backup SSAID
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "ssaid", "正在备份 SSAID…"))
|
||||
backupSsaid(app.packageName, appDir, userId)
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "ssaid", "正在备份 SSAID…"))
|
||||
backupSsaid(app.packageName.value, appDir, userId)
|
||||
|
||||
// 4.5 Backup app icon
|
||||
val iconPath = AppScanner.extractIcon(app.packageName.value, appDir, app.userId.value)
|
||||
if (iconPath != null) {
|
||||
Log.d(TAG, "backupApps: saved icon for ${app.packageName} -> $iconPath")
|
||||
}
|
||||
|
||||
// 5. Backup runtime permissions
|
||||
backupPermissions(app.packageName, appDir)
|
||||
backupPermissions(app.packageName.value, appDir)
|
||||
|
||||
successAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "完成"))
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "完成"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
RootShell.exec("chmod -R 0755 '${backupRoot.absolutePath}'")
|
||||
|
||||
val successCount = successAtomic.get()
|
||||
val failCount = failAtomic.get()
|
||||
val skippedCount = skippedAtomic.get()
|
||||
|
||||
LogUtil.i(TAG, "backupApps: completed — success=$successCount fail=$failCount skipped=$skippedCount elapsed=${elapsed}ms")
|
||||
|
||||
BackupResult(
|
||||
successCount = successAtomic.get(),
|
||||
failCount = failAtomic.get(),
|
||||
skippedCount = skippedAtomic.get(),
|
||||
successCount = successCount,
|
||||
failCount = failCount,
|
||||
skippedCount = skippedCount,
|
||||
outputDir = backupRoot.absolutePath,
|
||||
elapsedMs = elapsed
|
||||
)
|
||||
@@ -153,58 +189,125 @@ object BackupOperation {
|
||||
|
||||
|
||||
private suspend fun backupUserData(
|
||||
context: android.content.Context,
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
userId: String,
|
||||
compression: String
|
||||
): Boolean {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
val dataDir = "/data/data/$pkgEsc"
|
||||
val userDeDir = "/data/user_de/${userId.shellEscape()}/$pkgEsc"
|
||||
val outputFile = "${appDir.absolutePath.shellEscape()}/${pkgEsc}_data.tar"
|
||||
// Build a list of dirs that exist
|
||||
val dirs = mutableListOf<String>()
|
||||
if (RootShell.exec("test -d $dataDir").isSuccess) dirs.add(dataDir)
|
||||
if (RootShell.exec("test -d $userDeDir").isSuccess) dirs.add(userDeDir)
|
||||
if (dirs.isEmpty()) return true // no data to backup is not an error
|
||||
// Exclude cache, code_cache, lib
|
||||
val excludeArgs = "--exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup'"
|
||||
val result = when (compression) {
|
||||
"zstd" -> {
|
||||
val dirList = dirs.joinToString(" ")
|
||||
RootShell.exec(
|
||||
"tar $excludeArgs -cf - $dirList 2>/dev/null | zstd -T0 -o '$outputFile.zst'"
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val dirList = dirs.joinToString(" ")
|
||||
RootShell.exec(
|
||||
"tar $excludeArgs -czf '$outputFile.gz' $dirList 2>/dev/null"
|
||||
)
|
||||
|
||||
// Resolve bundled binary paths (fall back to system PATH if not bundled)
|
||||
val bundledTar = BinaryResolver.tarPath(context)
|
||||
val tarCmd = bundledTar ?: "tar"
|
||||
|
||||
var isZstd = compression == "zstd"
|
||||
val bundledZstd = if (isZstd) BinaryResolver.zstdPath(context) else null
|
||||
val zstdCmd = bundledZstd ?: "zstd"
|
||||
if (isZstd && bundledZstd == null) {
|
||||
val zstdCheck = RootShell.exec("$zstdCmd --version 2>/dev/null")
|
||||
if (!zstdCheck.isSuccess) {
|
||||
Log.w(TAG, "backupUserData: zstd not available, falling back to gzip")
|
||||
isZstd = false
|
||||
}
|
||||
}
|
||||
if (!result.isSuccess) {
|
||||
Log.e(TAG, "Failed to backup data for $packageName: exit=${result.exitCode} err=${result.error}")
|
||||
val archiveExt = if (isZstd) ".zst" else ".gz"
|
||||
val archiveRaw = File(appDir, "${packageName}_data.tar$archiveExt")
|
||||
|
||||
Log.d(TAG, "backupUserData: $packageName checking dirs (tar=$tarCmd zstd=$zstdCmd)")
|
||||
|
||||
val rawPkg = packageName
|
||||
val dataPaths = listOf("/data/data/$rawPkg", "/data/user_de/$userId/$rawPkg")
|
||||
val dataExcludes = listOf(".ota", "cache", "lib", "code_cache", "no_backup")
|
||||
|
||||
// 1. Try direct paths after nsenter namespace switch
|
||||
var archiveCreated = false
|
||||
var result: RootShell.ShellResult? = null
|
||||
|
||||
val dirs = dataPaths.filter { RootShell.exec("test -d '${it.shellEscape()}'").isSuccess }.toMutableList()
|
||||
if (dirs.isNotEmpty()) {
|
||||
Log.d(TAG, "backupUserData: $packageName test -d found dirs=$dirs")
|
||||
result = runTar(dirs, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
|
||||
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
|
||||
Log.d(TAG, "backupUserData: $packageName step1 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
|
||||
} else {
|
||||
Log.d(TAG, "backupUserData: $packageName test -d all failed, trying tar directly")
|
||||
result = runTar(dataPaths, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
|
||||
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
|
||||
Log.d(TAG, "backupUserData: $packageName step2 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
|
||||
}
|
||||
|
||||
// 3. Fallback via /proc/1/root (global mount namespace)
|
||||
if (!archiveCreated) {
|
||||
Log.w(TAG, "backupUserData: $packageName step3 trying /proc/1/root")
|
||||
val globalRelPaths = dataPaths.map { it.removePrefix("/") }
|
||||
val globalCmd = if (isZstd) {
|
||||
"cd /proc/1/root && set -o pipefail; $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
|
||||
} else {
|
||||
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null"
|
||||
}
|
||||
result = RootShell.exec(globalCmd)
|
||||
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
|
||||
Log.d(TAG, "backupUserData: $packageName step3 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
|
||||
}
|
||||
|
||||
if (!archiveCreated) {
|
||||
Log.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)")
|
||||
return true
|
||||
}
|
||||
|
||||
// Verify compression integrity
|
||||
val verifyOk = if (isZstd) {
|
||||
RootShell.exec("$zstdCmd -t '$outputFile.zst' 2>/dev/null").isSuccess
|
||||
} else {
|
||||
RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess
|
||||
}
|
||||
if (!verifyOk) {
|
||||
Log.e(TAG, "backupUserData: $packageName integrity check FAILED")
|
||||
return false
|
||||
}
|
||||
// Verify the compressed archive integrity
|
||||
val verificationOk = when (compression) {
|
||||
"zstd" -> RootShell.exec("zstd -t '$outputFile.zst' 2>/dev/null").isSuccess
|
||||
else -> RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess
|
||||
|
||||
// Validate tar archive structure (Android-DataBackup Tar.test() pattern)
|
||||
val tarValidateOk = if (isZstd) {
|
||||
RootShell.exec("$zstdCmd -d -c '$outputFile.zst' 2>/dev/null | tar -tf - > /dev/null 2>&1").isSuccess
|
||||
} else {
|
||||
RootShell.exec("tar -tf '$outputFile.gz' > /dev/null 2>&1").isSuccess
|
||||
}
|
||||
if (!verificationOk) {
|
||||
Log.e(TAG, "Data archive integrity check FAILED for $packageName")
|
||||
if (!tarValidateOk) {
|
||||
Log.e(TAG, "backupUserData: $packageName tar archive structure validation FAILED")
|
||||
return false
|
||||
}
|
||||
return verificationOk
|
||||
return true
|
||||
}
|
||||
|
||||
/** Run tar for given paths, building the appropriate zstd/gzip command. */
|
||||
private suspend fun runTar(
|
||||
dirs: List<String>,
|
||||
outputFile: String,
|
||||
isZstd: Boolean,
|
||||
tarCmd: String = "tar",
|
||||
zstdCmd: String = "zstd",
|
||||
excludes: List<String> = emptyList()
|
||||
): RootShell.ShellResult {
|
||||
val excludeArgs = if (excludes.isNotEmpty()) {
|
||||
excludes.joinToString(" ") { "--exclude='${it.shellEscape()}'" }
|
||||
} else ""
|
||||
return if (isZstd) {
|
||||
RootShell.exec("set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'")
|
||||
} else {
|
||||
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null")
|
||||
}
|
||||
}
|
||||
private suspend fun backupObb(packageName: String, appDir: File, compression: String): Boolean {
|
||||
val obbDir = "/storage/emulated/0/Android/obb/${packageName.shellEscape()}"
|
||||
val escapedAppDir = appDir.absolutePath.shellEscape()
|
||||
val escapedPkg = packageName.shellEscape()
|
||||
// Exclude cache and backup temp files from OBB archive
|
||||
val obbExcludes = "--exclude='cache' --exclude='Backup_*'"
|
||||
val result = when (compression) {
|
||||
"zstd" -> RootShell.exec("tar -cf - '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'")
|
||||
else -> RootShell.exec("tar -czf '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
|
||||
"zstd" -> RootShell.exec("set -o pipefail; tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'")
|
||||
else -> RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
|
||||
}
|
||||
if (!result.isSuccess) {
|
||||
Log.e(TAG, "Failed to backup OBB for $packageName: exit=${result.exitCode} err=${result.error}")
|
||||
@@ -216,14 +319,30 @@ object BackupOperation {
|
||||
if (!verificationOk) {
|
||||
Log.e(TAG, "OBB archive integrity check FAILED for $packageName")
|
||||
}
|
||||
return verificationOk
|
||||
// Validate OBB tar structure
|
||||
val tarListCmd = if (compression == "zstd") "zstd -d -c '$archive' 2>/dev/null | tar -tf - > /dev/null 2>&1" else "tar -tf '$archive' > /dev/null 2>&1"
|
||||
val tarOk = RootShell.exec(tarListCmd).isSuccess
|
||||
if (!tarOk) {
|
||||
Log.e(TAG, "OBB tar structure validation FAILED for $packageName")
|
||||
}
|
||||
return verificationOk && tarOk
|
||||
}
|
||||
|
||||
private suspend fun backupSsaid(packageName: String, appDir: File, userId: String) {
|
||||
val ssaidFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
|
||||
val result = RootShell.exec("grep '${packageName.shellEscape()}' '$ssaidFile' 2>/dev/null")
|
||||
if (result.output.isNotBlank()) {
|
||||
File(appDir, "ssaid.txt").writeText(result.output)
|
||||
// Parse XML value attribute for this package's SSAID entry
|
||||
val result = RootShell.exec("cat '$ssaidFile' 2>/dev/null")
|
||||
if (!result.isSuccess || result.output.isBlank()) return
|
||||
val ssaidLine = result.output.lines().firstOrNull { line ->
|
||||
line.contains("packageName=\"$packageName\"") || line.contains("packageName='$packageName'")
|
||||
}
|
||||
val value = ssaidLine
|
||||
?.substringAfter("value=\"")
|
||||
?.substringBefore("\"")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
if (value != null) {
|
||||
File(appDir, "ssaid.txt").writeText(value)
|
||||
Log.d(TAG, "backupSsaid: backed up SSAID for $packageName = $value")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,13 +353,35 @@ object BackupOperation {
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildAppDetailsJson(apps: List<AppInfo>): String {
|
||||
internal suspend fun buildAppDetailsJson(
|
||||
apps: List<AppInfo>,
|
||||
legacyApps: Map<String, SnapshotAppInfo>? = null
|
||||
): String {
|
||||
val root = JSONObject()
|
||||
// Generate fresh metadata for apps in the current app list
|
||||
for (app in apps) {
|
||||
val entry = JSONObject()
|
||||
entry.put("label", app.label)
|
||||
entry.put("isSystem", app.isSystem)
|
||||
root.put(app.packageName, entry)
|
||||
// Record APK file sizes for change detection in incremental backup
|
||||
val paths = AppScanner.getApkPaths(app.packageName.value)
|
||||
val sizes = paths.map { path ->
|
||||
val result = RootShell.exec("stat -c%s '${path.shellEscape()}'")
|
||||
if (result.isSuccess) result.output.trim().toLongOrNull() ?: 0L else 0L
|
||||
}
|
||||
entry.put("apkSizes", JSONArray(sizes))
|
||||
root.put(app.packageName.value, entry)
|
||||
}
|
||||
// Include legacy apps not in current app list with preserved metadata
|
||||
val legacyMap = legacyApps ?: emptyMap()
|
||||
for ((pkg, legacy) in legacyMap) {
|
||||
if (!root.has(pkg)) {
|
||||
val entry = JSONObject()
|
||||
entry.put("label", legacy.label)
|
||||
entry.put("isSystem", legacy.isSystem)
|
||||
entry.put("apkSizes", JSONArray(legacy.apkSizes))
|
||||
root.put(pkg, entry)
|
||||
}
|
||||
}
|
||||
return root.toString(2)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
/**
|
||||
* Foreground service to keep the process alive during long backup/restore operations.
|
||||
* Prevents Android from killing the app during extended operations.
|
||||
*/
|
||||
class BackupService : Service() {
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "backup_service_channel"
|
||||
const val NOTIFICATION_ID = 1001
|
||||
const val ACTION_START_BACKUP = "com.example.androidbackupgui.action.START_BACKUP"
|
||||
const val ACTION_STOP_BACKUP = "com.example.androidbackupgui.action.STOP_BACKUP"
|
||||
const val EXTRA_STATUS_TEXT = "status_text"
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START_BACKUP -> {
|
||||
val statusText = intent.getStringExtra(EXTRA_STATUS_TEXT) ?: "正在备份…"
|
||||
val notification = createNotification(statusText)
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
ACTION_STOP_BACKUP -> {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"备份服务",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "后台备份任务持续运行通知"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotification(text: String): Notification {
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Android Backup")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_upload)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Resolves paths to binaries bundled in jniLibs.
|
||||
* Android's PackageManager extracts lib*.so from jniLibs to nativeLibraryDir.
|
||||
* We copy them to app-private dir (writable, executable) for ProcessBuilder use.
|
||||
*/
|
||||
object BinaryResolver {
|
||||
private const val TAG = "BinaryResolver"
|
||||
|
||||
private var tarPath: String? = null
|
||||
private var zstdPath: String? = null
|
||||
|
||||
fun tarPath(context: Context): String? = cacheOrResolve(context, "libtar_bin.so", "tar_bin", ::tarPath) { tarPath = it }
|
||||
fun zstdPath(context: Context): String? = cacheOrResolve(context, "libzstd_bin.so", "zstd_bin", ::zstdPath) { zstdPath = it }
|
||||
|
||||
private fun cacheOrResolve(
|
||||
context: Context, libName: String, destName: String,
|
||||
cache: () -> String?, setCache: (String?) -> Unit
|
||||
): String? {
|
||||
val cached = cache()
|
||||
if (cached != null) return cached
|
||||
val resolved = resolve(context, libName, destName)
|
||||
setCache(resolved)
|
||||
return resolved
|
||||
}
|
||||
|
||||
private fun resolve(context: Context, libName: String, destName: String): String? {
|
||||
val nativeLibDir = context.applicationInfo.nativeLibraryDir
|
||||
val source = File(nativeLibDir, libName)
|
||||
if (!source.isFile) {
|
||||
Log.e(TAG, "$libName NOT FOUND at ${source.absolutePath}")
|
||||
return null
|
||||
}
|
||||
val dest = File(context.filesDir, "bin/$destName")
|
||||
if (!dest.exists() || dest.length() != source.length() || !dest.canExecute()) {
|
||||
dest.parentFile?.mkdirs()
|
||||
if (dest.exists()) dest.delete()
|
||||
source.inputStream().use { src -> dest.outputStream().use { out -> src.copyTo(out) } }
|
||||
dest.setExecutable(true)
|
||||
}
|
||||
Log.i(TAG, "ready: $libName -> ${dest.absolutePath} (${dest.length()} bytes) canExec=${dest.canExecute()}")
|
||||
return dest.absolutePath
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* File-based logger with rotation support.
|
||||
* Writes logs to [baseDir]/logs/YYYY-MM-dd.log, keeping up to [maxDays] days.
|
||||
* Also dispatches to Android Logcat for real-time visibility.
|
||||
*/
|
||||
object LogUtil {
|
||||
|
||||
private const val TAG = "LogUtil"
|
||||
private const val MAX_DAYS = 7
|
||||
|
||||
private var baseDir: File? = null
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
private val timestampFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
|
||||
|
||||
fun init(baseDir: File) {
|
||||
this.baseDir = baseDir
|
||||
executor.execute { rotateLogs() }
|
||||
}
|
||||
|
||||
fun i(tag: String, message: String) {
|
||||
Log.i(tag, message)
|
||||
writeLog("I", tag, message)
|
||||
}
|
||||
|
||||
fun w(tag: String, message: String) {
|
||||
Log.w(tag, message)
|
||||
writeLog("W", tag, message)
|
||||
}
|
||||
|
||||
fun e(tag: String, message: String) {
|
||||
Log.e(tag, message)
|
||||
writeLog("E", tag, message)
|
||||
}
|
||||
|
||||
private fun writeLog(level: String, tag: String, message: String) {
|
||||
val dir = baseDir ?: return
|
||||
executor.execute {
|
||||
try {
|
||||
val today = dateFormat.format(Date())
|
||||
val logFile = File(File(dir, "logs"), "$today.log")
|
||||
logFile.parentFile?.mkdirs()
|
||||
val timestamp = timestampFormat.format(Date())
|
||||
val line = "$timestamp $level/$tag: $message\n"
|
||||
logFile.appendText(line)
|
||||
} catch (_: Exception) {
|
||||
// Silently fail — logging should never crash the app
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rotateLogs() {
|
||||
val dir = baseDir ?: return
|
||||
val logDir = File(dir, "logs")
|
||||
if (!logDir.exists()) return
|
||||
|
||||
val cutoff = System.currentTimeMillis() - MAX_DAYS * 24L * 60 * 60 * 1000
|
||||
logDir.listFiles()
|
||||
?.filter { it.name.endsWith(".log") }
|
||||
?.forEach { file ->
|
||||
if (file.lastModified() < cutoff) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get all log files sorted by name (date ascending). */
|
||||
fun getLogFiles(): List<File> {
|
||||
val dir = baseDir ?: return emptyList()
|
||||
val logDir = File(dir, "logs")
|
||||
return logDir.listFiles()
|
||||
?.filter { it.name.endsWith(".log") }
|
||||
?.sortedBy { it.name }
|
||||
?: emptyList()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Manages remote transport lifecycle (SMB/WebDAV) and local temp repo sync.
|
||||
*
|
||||
* For SMB/WebDAV backends, restic runs against a local temp directory;
|
||||
* [RemoteTransport] syncs files to/from the remote backend.
|
||||
*
|
||||
* All sync operations are serialized via [repoSyncMutex] so concurrent
|
||||
* operations don't corrupt the local temp repo.
|
||||
*/
|
||||
class RemoteSyncManager {
|
||||
|
||||
private val TAG = "ResticWrapper"
|
||||
|
||||
/** Local temp directory used as restic repo for SMB/WebDAV backends. */
|
||||
@Volatile
|
||||
var tempRepoDir: String = ""
|
||||
|
||||
/** Domain for SMB NTLM authentication. */
|
||||
@Volatile
|
||||
var backendDomain: String = ""
|
||||
|
||||
// ── Transport cache ──────────────────────────────────
|
||||
@Volatile private var transport: RemoteTransport? = null
|
||||
private var transportConfigKey: String = ""
|
||||
private val transportLock = Any()
|
||||
|
||||
/** Serializes access to tempRepoDir so concurrent operations don't corrupt each other. */
|
||||
private val repoSyncMutex = Mutex()
|
||||
|
||||
// ── Transport lifecycle ──────────────────────────────
|
||||
|
||||
private fun ensureTransport(
|
||||
backend: String, url: String, user: String, pass: String, share: String, repoPath: String
|
||||
): RemoteTransport? = synchronized(transportLock) {
|
||||
val key = "$backend|$url|$user|$pass|$share|$backendDomain|$repoPath"
|
||||
if (key != transportConfigKey || transport == null) {
|
||||
transport?.let { Log.i(TAG, "transport config changed ($transportConfigKey -> $key), recreating") }
|
||||
// Clear local temp repo when backend config changes so
|
||||
// syncFromRemote downloads fresh data from the new backend
|
||||
if (transportConfigKey.isNotEmpty() && tempRepoDir.isNotEmpty()) {
|
||||
val dir = File(tempRepoDir)
|
||||
val deleted = dir.deleteRecursively()
|
||||
Log.i(TAG, "cleared local temp repo: $tempRepoDir (deleted=$deleted)")
|
||||
dir.mkdirs()
|
||||
}
|
||||
transport = RemoteTransport.create(backend, url, user, pass, share, backendDomain)
|
||||
if (transport != null) {
|
||||
transportConfigKey = key
|
||||
Log.i(TAG, "transport created: $backend @ $url repo=$repoPath domain=$backendDomain")
|
||||
} else {
|
||||
Log.e(TAG, "transport creation failed for backend=$backend url=$url")
|
||||
}
|
||||
}
|
||||
return transport
|
||||
}
|
||||
|
||||
// ── Temp dir lifecycle ───────────────────────────────
|
||||
|
||||
/** Clean up local temp repo and cache directories. */
|
||||
private fun cleanupTempDirs() {
|
||||
if (tempRepoDir.isEmpty()) return
|
||||
try {
|
||||
val repoDir = File(tempRepoDir)
|
||||
if (repoDir.exists()) {
|
||||
val deleted = repoDir.deleteRecursively()
|
||||
Log.i(TAG, "cleanupTempDirs: deleted $tempRepoDir ($deleted)")
|
||||
}
|
||||
val cacheDir = File(tempRepoDir.substringBeforeLast("/") + "/restic_cache")
|
||||
if (cacheDir.exists()) {
|
||||
val deleted = cacheDir.deleteRecursively()
|
||||
Log.i(TAG, "cleanupTempDirs: deleted cache $cacheDir ($deleted)")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "cleanupTempDirs failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** True if [tempRepoDir] already contains an initialized restic repository (has a config file). */
|
||||
private fun isLocalRepoPopulated(): Boolean {
|
||||
if (tempRepoDir.isEmpty()) return false
|
||||
return File(tempRepoDir, "config").isFile
|
||||
}
|
||||
|
||||
// ── Sync engine ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute [action] with remote repo synced before/after as needed.
|
||||
* For local/rest-server backends, executes [action] directly without sync.
|
||||
* Protected by [repoSyncMutex] so concurrent operations don't corrupt tempRepoDir.
|
||||
*
|
||||
* Cleanup strategy:
|
||||
* - Write ops (needsUpload=true): cleanup only on successful sync to remote.
|
||||
* On syncToRemote failure the local repo is preserved so the next
|
||||
* operation can retry — destroying it would lose the just-created snapshot.
|
||||
* - Read-only ops (needsUpload=false): keep local cache for subsequent operations.
|
||||
* - Read-only ops skip download entirely if local repo is already populated.
|
||||
*/
|
||||
suspend fun <T> withRemoteSync(
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
repoPath: String,
|
||||
needsDownload: Boolean,
|
||||
needsUpload: Boolean,
|
||||
onProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
action: suspend () -> Result<T>
|
||||
): Result<T> {
|
||||
if (backend != "smb" && backend != "webdav") return action()
|
||||
|
||||
return repoSyncMutex.withLock {
|
||||
var shouldCleanup = false
|
||||
try {
|
||||
val t = ensureTransport(backend, backendUrl, backendUser, backendPass, backendShare, repoPath)
|
||||
?: return@withLock Result.failure(Exception("Failed to create transport for backend: $backend"))
|
||||
|
||||
val localDir = File(tempRepoDir)
|
||||
|
||||
val emitProgress: suspend (RemoteTransport.TransferProgress) -> Unit = { p ->
|
||||
withContext(Dispatchers.Main) { onProgress(p) }
|
||||
}
|
||||
|
||||
// Write ops always download to avoid overwriting remote changes.
|
||||
// Read-only ops skip download if local repo is already present.
|
||||
val actualDownload = needsDownload && (needsUpload || !isLocalRepoPopulated())
|
||||
if (actualDownload) {
|
||||
Log.i(TAG, "syncFromRemote start: $repoPath -> $tempRepoDir")
|
||||
val syncResult = RemoteTransport.syncFromRemote(t, localDir, repoPath, emitProgress, onByteProgress)
|
||||
if (syncResult.isFailure) {
|
||||
shouldCleanup = true
|
||||
Log.e(TAG, "syncFromRemote FAILED: ${syncResult.exceptionOrNull()?.message}")
|
||||
return@withLock Result.failure(
|
||||
Exception("syncFromRemote failed: ${syncResult.exceptionOrNull()?.message}")
|
||||
)
|
||||
}
|
||||
Log.i(TAG, "syncFromRemote complete")
|
||||
} else if (needsDownload) {
|
||||
Log.i(TAG, "syncFromRemote skipped: local repo already populated")
|
||||
}
|
||||
|
||||
val result = action()
|
||||
|
||||
if (needsUpload && result.isSuccess) {
|
||||
Log.i(TAG, "syncToRemote start: $tempRepoDir -> $repoPath")
|
||||
val uploadResult = RemoteTransport.syncToRemote(t, localDir, repoPath, emitProgress, onByteProgress)
|
||||
if (uploadResult.isFailure) {
|
||||
shouldCleanup = false // PRESERVE local repo — snapshot would be lost
|
||||
Log.e(TAG, "syncToRemote FAILED: ${uploadResult.exceptionOrNull()?.message} — local repo preserved for retry")
|
||||
return@withLock Result.failure(
|
||||
Exception("syncToRemote failed: ${uploadResult.exceptionOrNull()?.message}")
|
||||
)
|
||||
}
|
||||
Log.i(TAG, "syncToRemote complete")
|
||||
shouldCleanup = true
|
||||
} else if (result.isFailure) {
|
||||
shouldCleanup = true
|
||||
}
|
||||
|
||||
result
|
||||
} catch (e: CancellationException) {
|
||||
shouldCleanup = true
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
shouldCleanup = true
|
||||
Result.failure(e)
|
||||
} finally {
|
||||
if (shouldCleanup) {
|
||||
Log.i(TAG, "withRemoteSync: cleaning up temp dirs")
|
||||
cleanupTempDirs()
|
||||
} else {
|
||||
Log.d(TAG, "withRemoteSync: keeping local repo for subsequent ops")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public safety-net cleanup called by fragment lifecycle.
|
||||
* Waits for any in-progress operation to finish, then deletes temp dirs.
|
||||
*/
|
||||
suspend fun cleanup() {
|
||||
repoSyncMutex.withLock { cleanupTempDirs() }
|
||||
}
|
||||
}
|
||||
@@ -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,244 +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 downloaded = 0
|
||||
val syncTotal = remoteFiles.size
|
||||
for ((relPath, info) in remoteByPath) {
|
||||
downloaded++
|
||||
onProgress(TransferProgress("download", downloaded, syncTotal, relPath))
|
||||
val localFile = File(localDir, relPath)
|
||||
if (localFile.isFile && localFile.length() == info.size) {
|
||||
Log.d(TAG, "syncFromRemote skip (same size): $relPath")
|
||||
continue
|
||||
}
|
||||
localFile.parentFile?.mkdirs()
|
||||
val fullRemotePath = "$remoteDir/$relPath"
|
||||
Log.i(TAG, "syncFromRemote downloading: $fullRemotePath (${info.size} bytes)")
|
||||
val result = withRetry("download($fullRemotePath)") {
|
||||
transport.download(fullRemotePath, localFile.absolutePath, onProgress, onByteProgress)
|
||||
}
|
||||
if (result.isFailure) {
|
||||
errors.add("$fullRemotePath: ${result.exceptionOrNull()?.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// If any download failed, abort before deleting local files —
|
||||
// deleting would destroy valid data for an incomplete sync.
|
||||
if (errors.isNotEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
Exception("syncFromRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}")
|
||||
)
|
||||
}
|
||||
|
||||
// Delete local files not on remote (e.g. after prune on another client)
|
||||
val localFiles = walkLocalFiles(localDir)
|
||||
val staleLocalPaths = localFiles.keys.filter { it !in remoteByPath }
|
||||
val staleCount = staleLocalPaths.size
|
||||
for ((staleIdx, relPath) in staleLocalPaths.withIndex()) {
|
||||
onProgress(TransferProgress("delete_stale", staleIdx + 1, staleCount))
|
||||
val localFile = localFiles[relPath]!!
|
||||
Log.i(TAG, "syncFromRemote deleting stale local: $relPath")
|
||||
try { localFile.delete() } catch (_: Exception) {}
|
||||
}
|
||||
onProgress(TransferProgress("complete", syncTotal, syncTotal))
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("syncFromRemote failed: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload all files from [localDir] into [remoteDir] recursively,
|
||||
* skipping files that already exist remotely with the same size.
|
||||
* Deletes remote files that no longer exist locally.
|
||||
* Returns failure if any upload fails.
|
||||
*/
|
||||
suspend fun syncToRemote(
|
||||
transport: RemoteTransport,
|
||||
localDir: File,
|
||||
remoteDir: String,
|
||||
onProgress: suspend (TransferProgress) -> Unit = {},
|
||||
onByteProgress: suspend (ByteProgress) -> Unit = {}
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val localFiles = walkLocalFiles(localDir)
|
||||
onProgress(TransferProgress("list", 0, localFiles.size))
|
||||
val remoteResult = listRemoteRecursive(transport, remoteDir)
|
||||
// If the remote dir is not accessible (404 or network error), treat as empty.
|
||||
// Any real upload errors will surface during the actual file uploads below.
|
||||
if (remoteResult == null) {
|
||||
Log.w(TAG, "syncToRemote: remote dir '$remoteDir' not accessible, treating as empty")
|
||||
}
|
||||
val remoteByPath = (remoteResult ?: emptyList()).associateBy { it.path }
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
// Collect unique parent directories that need to exist on remote
|
||||
val remoteDirs = mutableSetOf<String>()
|
||||
for (relPath in localFiles.keys) {
|
||||
val parent = relPath.substringBeforeLast("/", "")
|
||||
if (parent.isNotEmpty()) remoteDirs.add(parent)
|
||||
}
|
||||
|
||||
// Ensure all remote directories exist
|
||||
for (dir in remoteDirs) {
|
||||
transport.mkdirs("$remoteDir/$dir")
|
||||
}
|
||||
|
||||
// Upload new or changed local files
|
||||
var uploaded = 0
|
||||
val syncTotal = localFiles.size
|
||||
for ((relPath, localFile) in localFiles) {
|
||||
uploaded++
|
||||
onProgress(TransferProgress("upload", uploaded, syncTotal, relPath))
|
||||
val remoteInfo = remoteByPath[relPath]
|
||||
if (remoteInfo != null && remoteInfo.size == localFile.length()) {
|
||||
Log.d(TAG, "syncToRemote skip (same size): $relPath")
|
||||
continue
|
||||
}
|
||||
val fullRemotePath = "$remoteDir/$relPath"
|
||||
Log.i(TAG, "syncToRemote uploading: $fullRemotePath (${localFile.length()} bytes)")
|
||||
val result = withRetry("upload($fullRemotePath)") {
|
||||
transport.upload(localFile.absolutePath, fullRemotePath, onProgress, onByteProgress)
|
||||
}
|
||||
if (result.isFailure) {
|
||||
errors.add("$fullRemotePath: ${result.exceptionOrNull()?.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// If any upload failed, abort before deleting remote files —
|
||||
// deleting during failed sync could lose the only copy on remote.
|
||||
if (errors.isNotEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
Exception("syncToRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}")
|
||||
)
|
||||
}
|
||||
|
||||
// Delete remote files no longer present locally
|
||||
val staleRemotePaths = remoteByPath.keys.filter { it !in localFiles }
|
||||
val staleCount = staleRemotePaths.size
|
||||
for ((staleIdx, relPath) in staleRemotePaths.withIndex()) {
|
||||
onProgress(TransferProgress("delete_stale", staleIdx + 1, staleCount))
|
||||
Log.i(TAG, "syncToRemote deleting stale: $relPath")
|
||||
transport.delete("$remoteDir/$relPath")
|
||||
}
|
||||
onProgress(TransferProgress("complete", localFiles.size, localFiles.size))
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("syncToRemote failed: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private data class FlatFileInfo(val path: String, val size: Long)
|
||||
|
||||
/** Recursively list all files on the remote. Returns null on failure.
|
||||
* Depth-limited to avoid redundant requests on servers that report
|
||||
* files as directories or return self-referencing PROPFIND entries. */
|
||||
private const val MAX_RECURSE_DEPTH = 3
|
||||
|
||||
private suspend fun listRemoteRecursive(
|
||||
transport: RemoteTransport,
|
||||
remoteDir: String
|
||||
): List<FlatFileInfo>? {
|
||||
val result = mutableListOf<FlatFileInfo>()
|
||||
// Pair of (relativePath, depth)
|
||||
val dirsToVisit = mutableListOf("" to 0)
|
||||
|
||||
while (dirsToVisit.isNotEmpty()) {
|
||||
val (subDir, depth) = dirsToVisit.removeLast()
|
||||
if (depth >= MAX_RECURSE_DEPTH) {
|
||||
Log.w(TAG, "listRemoteRecursive: max depth $MAX_RECURSE_DEPTH reached at $remoteDir/$subDir")
|
||||
continue
|
||||
}
|
||||
val fullDir = if (subDir.isEmpty()) remoteDir else "$remoteDir/$subDir"
|
||||
val listResult = withRetry("listFiles($fullDir)") {
|
||||
transport.listFiles(fullDir)
|
||||
}
|
||||
if (listResult.isFailure) {
|
||||
val err = listResult.exceptionOrNull()
|
||||
// 404 on a subdirectory: directory doesn't exist, skip it silently.
|
||||
// 404 on the root directory: fatal — the remote repo path may be wrong.
|
||||
if (err is FileNotFoundException) {
|
||||
if (subDir.isEmpty()) {
|
||||
Log.e(TAG, "listRemoteRecursive: root dir '$fullDir' returned 404 — repo may not exist or is rate-limited")
|
||||
return null
|
||||
}
|
||||
Log.d(TAG, "listRemoteRecursive: $fullDir -> 404, skipping")
|
||||
continue
|
||||
}
|
||||
Log.e(TAG, "listRemoteRecursive: listFiles FAILED for '$fullDir': ${err?.message}")
|
||||
return null
|
||||
}
|
||||
val entries = listResult.getOrThrow()
|
||||
val parentName = subDir.substringAfterLast("/", subDir)
|
||||
|
||||
for (entry in entries) {
|
||||
val relPath = if (subDir.isEmpty()) entry.name else "$subDir/${entry.name}"
|
||||
if (entry.isDirectory) {
|
||||
// Skip self-referencing entries where the server returns
|
||||
// the directory itself as a child (e.g. data/f9/ contains "f9")
|
||||
if (entry.name == parentName) {
|
||||
Log.d(TAG, "listRemoteRecursive skip self-ref: $relPath")
|
||||
continue
|
||||
}
|
||||
dirsToVisit.add(relPath to depth + 1)
|
||||
} else {
|
||||
result.add(FlatFileInfo(relPath, entry.size))
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "listRemoteRecursive: $remoteDir → ${result.size} files in ${result.map { it.path }.toSet().size} paths")
|
||||
return result
|
||||
}
|
||||
|
||||
/** Walk the local directory tree, returning relative-path → File mapping for all files. */
|
||||
private fun walkLocalFiles(localDir: File): Map<String, File> {
|
||||
val result = mutableMapOf<String, File>()
|
||||
val dirsToVisit = mutableListOf(localDir)
|
||||
val basePath = localDir.absolutePath
|
||||
|
||||
while (dirsToVisit.isNotEmpty()) {
|
||||
val dir = dirsToVisit.removeLast()
|
||||
for (file in dir.listFiles() ?: emptyArray()) {
|
||||
if (file.isDirectory) {
|
||||
dirsToVisit.add(file)
|
||||
} else {
|
||||
val relPath = file.absolutePath.removePrefix("$basePath/")
|
||||
result[relPath] = file
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Extension to check if an [AppError] represents a "not found" remote error. */
|
||||
internal fun AppError.isFileNotFound(): Boolean =
|
||||
this is AppError.Remote && this.isNotFound
|
||||
|
||||
@@ -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(60_000)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,24 +4,27 @@ import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
import java.io.File
|
||||
|
||||
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
|
||||
private val resticJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* Backup operations: running restic backup and parsing its summary output.
|
||||
*
|
||||
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||
* [RemoteSyncManager] which are shared across sub-modules.
|
||||
* [RestBridgeRunner] which are shared across sub-modules.
|
||||
*/
|
||||
class ResticBackup(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val syncManager: RemoteSyncManager
|
||||
private val bridgeRunner: RestBridgeRunner
|
||||
) {
|
||||
private val TAG = "ResticWrapper"
|
||||
private val TAG = "ResticBackup"
|
||||
var cacheDir: String = ""
|
||||
var backendDomain: String = ""
|
||||
|
||||
// ── Backup ─────────────────────────────────────────
|
||||
|
||||
@@ -36,51 +39,119 @@ class ResticBackup(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
|
||||
): Result<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
|
||||
): AppResult<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = true,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
|
||||
if (backend == "local") {
|
||||
val args = mutableListOf("backup", "--json")
|
||||
for (path in paths) args.add(path)
|
||||
for (tag in tags) { args.add("--tag"); args.add(tag) }
|
||||
if (hostname != null) { args.add("--host"); args.add(hostname) }
|
||||
|
||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runResticStreaming(env, args) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (_: Exception) { /* ignore non-JSON lines */ }
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
return@withRemoteSync Result.failure(Exception("restic backup failed: ${result.stderr}"))
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) return@withContext err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
|
||||
parseBackupSummary(result.stdout)
|
||||
} else {
|
||||
bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl ->
|
||||
val args = mutableListOf("backup", "--json")
|
||||
for (path in paths) args.add(path)
|
||||
for (tag in tags) { args.add("--tag"); args.add(tag) }
|
||||
if (hostname != null) { args.add("--host"); args.add(hostname) }
|
||||
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runResticStreaming(env, args) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) return@withBridge err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
|
||||
parseBackupSummary(result.stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Streaming backup (stdin) ──────────────────────
|
||||
|
||||
/**
|
||||
* Run restic backup in --stdin mode, reading tar data from [stdinFile] (FIFO).
|
||||
* [extraPaths] are files/directories backed up alongside the streaming data
|
||||
* (e.g. APK paths, metadata directory).
|
||||
*/
|
||||
suspend fun backupStdin(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
stdinFile: File,
|
||||
extraPaths: List<String>,
|
||||
tags: List<String> = emptyList(),
|
||||
hostname: String? = null,
|
||||
backend: String = "local",
|
||||
backendUrl: String = "",
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
|
||||
): AppResult<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
|
||||
val args = mutableListOf("backup", "--json", "--stdin", "--stdin-filename", "app_data.tar")
|
||||
for (path in extraPaths) args.add(path)
|
||||
for (tag in tags) { args.add("--tag"); args.add(tag) }
|
||||
if (hostname != null) { args.add("--host"); args.add(hostname) }
|
||||
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runResticWithStdin(env, args, stdinFile) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticWithStdin
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) return@withContext err(AppError.Restic("restic stream backup 失败", result.exitCode, result.stderr))
|
||||
parseBackupSummary(result.stdout)
|
||||
} else {
|
||||
bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runResticWithStdin(env, args, stdinFile) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticWithStdin
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) return@withBridge err(AppError.Restic("restic stream backup 失败", result.exitCode, result.stderr))
|
||||
parseBackupSummary(result.stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal helpers ───────────────────────────────
|
||||
|
||||
/** Parse the JSON summary from the end of restic backup output. */
|
||||
private fun parseBackupSummary(stdout: String): Result<ResticWrapper.BackupSummary> {
|
||||
private fun parseBackupSummary(stdout: String): AppResult<ResticWrapper.BackupSummary> {
|
||||
val lines = stdout.lines()
|
||||
for (i in lines.indices.reversed()) {
|
||||
val line = lines[i].trim()
|
||||
if (!line.startsWith("{")) continue
|
||||
try {
|
||||
val summary = resticJson.decodeFromString<ResticWrapper.BackupSummary>(line)
|
||||
if (summary.snapshotId.isNotEmpty()) return Result.success(summary)
|
||||
if (summary.messageType == "summary" && summary.snapshotId.isNotEmpty()) return AppResult.Success(summary)
|
||||
} catch (_: Exception) { /* keep looking */ }
|
||||
}
|
||||
return Result.failure(Exception("No summary found in restic output"))
|
||||
return err(AppError.Parse("restic 备份输出未找到摘要信息", "stdout=" + stdout.length))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,8 @@ object ResticBinary {
|
||||
synchronized(this) {
|
||||
if (cacheInit) return cachedBinaryPath
|
||||
val nativeLibDir = context.applicationInfo.nativeLibraryDir
|
||||
Log.d(TAG, "nativeLibraryDir = $nativeLibDir")
|
||||
|
||||
val path = File(nativeLibDir, BINARY_NAME)
|
||||
Log.d(TAG, "restic: exists=${path.isFile} len=${path.length()} canExec=${path.canExecute()}")
|
||||
Log.d(TAG, "nativeLibraryDir=$nativeLibDir exists=${path.isFile} len=${path.length()} canExec=${path.canExecute()}")
|
||||
|
||||
cachedBinaryPath = if (path.isFile) {
|
||||
Log.i(TAG, "librestic.so ready at ${path.absolutePath} (${path.length()} bytes)")
|
||||
@@ -35,13 +33,6 @@ object ResticBinary {
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the temp directory used as local restic repo for remote backends. */
|
||||
fun getTempRepoDir(context: Context): String {
|
||||
val dir = File(context.cacheDir, "restic_remote_repo")
|
||||
dir.mkdirs()
|
||||
Log.d(TAG, "tempRepoDir = ${dir.absolutePath}")
|
||||
return dir.absolutePath
|
||||
}
|
||||
|
||||
fun isReady(): Boolean = false // call prepare() instead
|
||||
fun isReady(): Boolean = cachedBinaryPath != null
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@ package com.example.androidbackupgui.backup
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
@@ -27,43 +31,53 @@ class ResticCommandRunner {
|
||||
)
|
||||
|
||||
/** Build the full command list to run restic. */
|
||||
fun buildCommandArgs(args: List<String>): List<String> {
|
||||
val cmd = listOf(binaryPath) + args
|
||||
Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args → cmd=$cmd")
|
||||
return cmd
|
||||
}
|
||||
fun buildCommandArgs(args: List<String>): List<String> =
|
||||
(listOf(binaryPath) + args).also { cmd ->
|
||||
Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args -> cmd=$cmd")
|
||||
}
|
||||
|
||||
/** Run restic (non-streaming). */
|
||||
fun runRestic(env: Map<String, String>, args: List<String>): CommandResult {
|
||||
val cmdArgs = buildCommandArgs(args)
|
||||
Log.i(TAG, "runRestic cmd=${cmdArgs.joinToString(" ")}")
|
||||
Log.d(TAG, "runRestic REPOSITORY=${env["RESTIC_REPOSITORY"]}")
|
||||
|
||||
// NOTE: Do NOT log RESTIC_PASSWORD or any value derived from it.
|
||||
// RESTIC_REPOSITORY is safe to log (does not contain secrets).
|
||||
env["TMPDIR"]?.let { File(it).mkdirs() }
|
||||
return try {
|
||||
val pb = ProcessBuilder(cmdArgs)
|
||||
pb.environment().putAll(env)
|
||||
pb.redirectErrorStream(false)
|
||||
val process = pb.start()
|
||||
|
||||
val stderrText = StringBuilder()
|
||||
val stderrThread = Thread({
|
||||
try {
|
||||
process.errorStream.bufferedReader().use { reader ->
|
||||
var line: String?
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
Log.d(TAG, "restic stderr: $line")
|
||||
stderrText.appendLine(line)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}, "restic-stderr").apply { isDaemon = true; start() }
|
||||
|
||||
val stdout = process.inputStream.bufferedReader().use(BufferedReader::readText)
|
||||
val exitCode = process.waitFor()
|
||||
stderrThread.join(5000)
|
||||
val stderrBytes = process.errorStream.use { it.readAllBytesCompat() }
|
||||
val exitCode = try {
|
||||
val deadline = System.currentTimeMillis() + 60_000
|
||||
var exited = false
|
||||
while (System.currentTimeMillis() < deadline && !exited) {
|
||||
try {
|
||||
process.exitValue()
|
||||
exited = true
|
||||
} catch (_: IllegalThreadStateException) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
}
|
||||
if (exited) {
|
||||
process.exitValue()
|
||||
} else {
|
||||
Log.w(TAG, "runRestic: process did not exit within 60s, destroying")
|
||||
process.destroy()
|
||||
process.waitFor()
|
||||
process.exitValue()
|
||||
}
|
||||
} catch (_: Exception) { -1 }
|
||||
val stderrText = stderrBytes.decodeToString()
|
||||
Log.i(TAG, "runRestic exitCode=$exitCode stdout_len=${stdout.length}")
|
||||
if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText}")
|
||||
CommandResult(stdout.trim(), stderrText.toString().trim(), exitCode)
|
||||
if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText.trim()}")
|
||||
CommandResult(stdout.trim(), stderrText.trim(), exitCode)
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "runRestic exception", e)
|
||||
CommandResult("", e.message ?: "Unknown error", -1)
|
||||
@@ -84,6 +98,7 @@ class ResticCommandRunner {
|
||||
val cmdArgs = buildCommandArgs(args)
|
||||
Log.i(TAG, "runResticStreaming cmd=${cmdArgs.joinToString(" ")}")
|
||||
Log.d(TAG, "runResticStreaming REPOSITORY=${env["RESTIC_REPOSITORY"]}")
|
||||
env["TMPDIR"]?.let { File(it).mkdirs() }
|
||||
|
||||
var process: Process? = null
|
||||
try {
|
||||
@@ -94,28 +109,22 @@ class ResticCommandRunner {
|
||||
|
||||
val stdoutText = StringBuilder()
|
||||
val reader = process.inputStream.bufferedReader()
|
||||
val stderrReader = process.errorStream.bufferedReader()
|
||||
|
||||
val stderrText = StringBuilder()
|
||||
val stderrThread = Thread({
|
||||
try { stderrReader.use { stderrText.append(it.readText()) } } catch (_: Exception) {}
|
||||
}, "restic-stderr").apply { isDaemon = true; start() }
|
||||
|
||||
try {
|
||||
var line: String?
|
||||
var line: String
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
if (!coroutineContext.isActive) {
|
||||
process.destroy()
|
||||
break
|
||||
}
|
||||
val l = line!!
|
||||
stdoutText.appendLine(l)
|
||||
onLine(l)
|
||||
stdoutText.appendLine(line)
|
||||
onLine(line)
|
||||
}
|
||||
} finally {
|
||||
try { reader.close() } catch (_: Exception) {}
|
||||
}
|
||||
stderrThread.join(5000)
|
||||
val stderrBytes = try { process.errorStream.use { it.readAllBytesCompat() } } catch (_: Exception) { byteArrayOf() }
|
||||
val stderrText = stderrBytes.decodeToString().trim()
|
||||
val exitCode = try {
|
||||
// Manual timeout loop (Process.waitFor(timeout,unit) requires API 26+)
|
||||
val deadline = System.currentTimeMillis() + 60_000
|
||||
@@ -140,11 +149,112 @@ class ResticCommandRunner {
|
||||
|
||||
Log.i(TAG, "runResticStreaming exitCode=$exitCode stdout_len=${stdoutText.length}")
|
||||
if (stderrText.isNotEmpty()) Log.w(TAG, "runResticStreaming stderr: ${stderrText}")
|
||||
CommandResult(stdoutText.toString().trim(), stderrText.toString().trim(), exitCode)
|
||||
CommandResult(stdoutText.toString().trim(), stderrText.trim(), exitCode)
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "runResticStreaming exception", e)
|
||||
try { process?.destroy() } catch (_: Exception) {}
|
||||
CommandResult("", e.message ?: "Unknown error", -1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run restic with stdin redirected from [stdinFile] (FIFO or regular file).
|
||||
* Calls [onLine] for each stdout line (for streaming progress).
|
||||
*/
|
||||
suspend fun runResticWithStdin(
|
||||
env: Map<String, String>,
|
||||
args: List<String>,
|
||||
stdinFile: File,
|
||||
onLine: suspend (String) -> Unit
|
||||
): CommandResult = withContext(Dispatchers.IO) {
|
||||
val cmdArgs = buildCommandArgs(args)
|
||||
Log.i(TAG, "runResticWithStdin cmd=${cmdArgs.joinToString(" ")} stdin=${stdinFile.absolutePath}")
|
||||
Log.d(TAG, "runResticWithStdin REPOSITORY=${env["RESTIC_REPOSITORY"]}")
|
||||
env["TMPDIR"]?.let { File(it).mkdirs() }
|
||||
|
||||
var process: Process? = null
|
||||
try {
|
||||
|
||||
val pb = ProcessBuilder(cmdArgs)
|
||||
pb.environment().putAll(env)
|
||||
pb.redirectErrorStream(false)
|
||||
process = pb.start()
|
||||
|
||||
// Pipe stdin from file to process on a daemon thread (API 24 compat)
|
||||
Thread {
|
||||
try {
|
||||
val fis = java.io.FileInputStream(stdinFile)
|
||||
val pos = process!!.outputStream
|
||||
fis.use { input -> pos.use { output -> input.copyTo(output) } }
|
||||
} catch (_: Exception) {
|
||||
// FIFO writer closed; stdin pipe ends naturally
|
||||
}
|
||||
}.apply { isDaemon = true; start() }
|
||||
val stdoutText = StringBuilder()
|
||||
val reader = process.inputStream.bufferedReader()
|
||||
|
||||
try {
|
||||
var line: String
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
if (!coroutineContext.isActive) {
|
||||
process.destroy()
|
||||
break
|
||||
}
|
||||
stdoutText.appendLine(line)
|
||||
onLine(line)
|
||||
}
|
||||
} finally {
|
||||
try { reader.close() } catch (_: Exception) {}
|
||||
}
|
||||
val stderrBytes = try { process.errorStream.use { it.readAllBytesCompat() } } catch (_: Exception) { byteArrayOf() }
|
||||
val stderrText = stderrBytes.decodeToString().trim()
|
||||
val exitCode = try {
|
||||
val deadline = System.currentTimeMillis() + 60_000
|
||||
var exited = false
|
||||
while (System.currentTimeMillis() < deadline && !exited) {
|
||||
try {
|
||||
process.exitValue()
|
||||
exited = true
|
||||
} catch (_: IllegalThreadStateException) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
}
|
||||
if (exited) {
|
||||
process.exitValue()
|
||||
} else {
|
||||
Log.w(TAG, "runResticWithStdin: process did not exit within 60s, destroying")
|
||||
process.destroy()
|
||||
process.waitFor()
|
||||
process.exitValue()
|
||||
}
|
||||
} catch (_: Exception) { -1 }
|
||||
|
||||
Log.i(TAG, "runResticWithStdin exitCode=$exitCode stdout_len=${stdoutText.length}")
|
||||
if (stderrText.isNotEmpty()) Log.w(TAG, "runResticWithStdin stderr: ${stderrText}")
|
||||
CommandResult(stdoutText.toString().trim(), stderrText.trim(), exitCode)
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "runResticWithStdin exception", e)
|
||||
try { process?.destroy() } catch (_: Exception) {}
|
||||
CommandResult("", e.message ?: "Unknown error", -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compat implementation of InputStream.readAllBytes() for API < 33.
|
||||
* Reads the entire stream into a byte array.
|
||||
*/
|
||||
private fun InputStream.readAllBytesCompat(): ByteArray {
|
||||
val buffer = ByteArrayOutputStream()
|
||||
val data = ByteArray(4096)
|
||||
while (true) {
|
||||
val n = read(data)
|
||||
if (n == -1) break
|
||||
buffer.write(data, 0, n)
|
||||
}
|
||||
return buffer.toByteArray()
|
||||
}
|
||||
|
||||
@@ -5,30 +5,39 @@ package com.example.androidbackupgui.backup
|
||||
*/
|
||||
class ResticEnvResolver {
|
||||
|
||||
/** Build environment for restic. For SMB/WebDAV backends, uses local temp dir as repo. */
|
||||
fun buildFullEnv(
|
||||
repoPath: String,
|
||||
|
||||
/** Build environment for non-local backends using the REST bridge URL. */
|
||||
fun buildBridgeEnv(
|
||||
password: String,
|
||||
backend: String = "local",
|
||||
backendUrl: String = "",
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
tempRepoDir: String = ""
|
||||
bridgeUrl: String,
|
||||
cacheDir: String
|
||||
): Map<String, String> {
|
||||
val env = HashMap(System.getenv() ?: emptyMap())
|
||||
env["RESTIC_REPOSITORY"] = if (backend == "smb" || backend == "webdav") {
|
||||
tempRepoDir
|
||||
} else {
|
||||
buildRepoUrl(backend, repoPath, backendUrl)
|
||||
}
|
||||
env["RESTIC_REPOSITORY"] = bridgeUrl
|
||||
env["RESTIC_PASSWORD"] = password
|
||||
// Restic needs HOME for its cache on Android (no $HOME by default).
|
||||
// Both local and remote backends use the same cache dir (sibling of tempRepoDir).
|
||||
if (tempRepoDir.isNotEmpty()) {
|
||||
val cacheDir = tempRepoDir.substringBeforeLast("/") + "/restic_cache"
|
||||
if (cacheDir.isNotEmpty()) {
|
||||
env["HOME"] = cacheDir
|
||||
env["XDG_CACHE_HOME"] = cacheDir
|
||||
val tmpDir = "$cacheDir/restic_tmp"
|
||||
env["TMPDIR"] = tmpDir
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/** Build environment for local repository. */
|
||||
fun buildLocalEnv(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
cacheDir: String
|
||||
): Map<String, String> {
|
||||
val env = HashMap(System.getenv() ?: emptyMap())
|
||||
env["RESTIC_REPOSITORY"] = repoPath
|
||||
env["RESTIC_PASSWORD"] = password
|
||||
if (cacheDir.isNotEmpty()) {
|
||||
env["HOME"] = cacheDir
|
||||
env["XDG_CACHE_HOME"] = cacheDir
|
||||
val tmpDir = "$cacheDir/restic_tmp"
|
||||
env["TMPDIR"] = tmpDir
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
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> {
|
||||
return try {
|
||||
val tmpFile = File(tmpDir, "restic_blob_${UUID.randomUUID()}")
|
||||
val input = (session as NanoHTTPD.HTTPSession).inputStream
|
||||
tmpFile.outputStream().use { output -> input.copyTo(output) }
|
||||
Result.success(tmpFile)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "stream body to file failed", 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 -> {
|
||||
newChunkedResponse(Response.Status.OK, "application/octet-stream", tempFile.inputStream())
|
||||
}
|
||||
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 — stream directly without loading into memory
|
||||
val response = newChunkedResponse(
|
||||
Response.Status.OK,
|
||||
"application/octet-stream",
|
||||
tempFile.inputStream()
|
||||
)
|
||||
response.addHeader("Content-Length", tempFile.length().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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerialName
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
|
||||
/**
|
||||
* Wraps the restic CLI binary for backup/restore operations.
|
||||
@@ -15,13 +19,14 @@ import kotlinx.serialization.SerialName
|
||||
* Uses environment variables (RESTIC_REPOSITORY, RESTIC_PASSWORD) rather than
|
||||
* command-line flags to avoid leaking secrets in the process list.
|
||||
*
|
||||
* For SMB/WebDAV backends, restic runs against a local temp directory;
|
||||
* RemoteTransport syncs files to/from the remote backend.
|
||||
* For SMB/WebDAV backends, restic connects via a local REST bridge
|
||||
* ([ResticRestBridge]) that translates HTTP requests to [RemoteTransport] calls,
|
||||
* eliminating the need for a local staging repo and full-directory sync.
|
||||
*
|
||||
* All public methods are suspend and run on Dispatchers.IO.
|
||||
*
|
||||
* This object is a facade that delegates to [ResticCommandRunner],
|
||||
* [ResticEnvResolver], [RemoteSyncManager], and sub-module classes
|
||||
* [ResticEnvResolver], [RestBridgeRunner], and sub-module classes
|
||||
* ([ResticRepoInit], [ResticBackup], [ResticRestore], [ResticSnapshotOps],
|
||||
* [ResticMaintenance]).
|
||||
*/
|
||||
@@ -31,15 +36,15 @@ object ResticWrapper {
|
||||
|
||||
private val runner = ResticCommandRunner()
|
||||
private val envResolver = ResticEnvResolver()
|
||||
private val syncManager = RemoteSyncManager()
|
||||
private val bridgeRunner = RestBridgeRunner()
|
||||
|
||||
// ── Sub-module instances ───────────────────────────
|
||||
|
||||
private val repoInit = ResticRepoInit(runner, envResolver, syncManager)
|
||||
private val backupOp = ResticBackup(runner, envResolver, syncManager)
|
||||
private val restoreOp = ResticRestore(runner, envResolver, syncManager)
|
||||
private val snapshotOps = ResticSnapshotOps(runner, envResolver, syncManager)
|
||||
private val maintenance = ResticMaintenance(runner, envResolver, syncManager)
|
||||
private val repoInit = ResticRepoInit(runner, envResolver, bridgeRunner)
|
||||
private val backupOp = ResticBackup(runner, envResolver, bridgeRunner)
|
||||
private val restoreOp = ResticRestore(runner, envResolver, bridgeRunner)
|
||||
private val snapshotOps = ResticSnapshotOps(runner, envResolver, bridgeRunner)
|
||||
private val maintenance = ResticMaintenance(runner, envResolver, bridgeRunner)
|
||||
|
||||
// ── Property delegation ───────────────────────────
|
||||
|
||||
@@ -48,16 +53,28 @@ object ResticWrapper {
|
||||
get() = runner.binaryPath
|
||||
set(v) { runner.binaryPath = v }
|
||||
|
||||
/** Local temp directory used as restic repo for SMB/WebDAV backends. */
|
||||
var tempRepoDir: String
|
||||
get() = syncManager.tempRepoDir
|
||||
set(v) { syncManager.tempRepoDir = v }
|
||||
/** Cache directory for restic (XDG_CACHE_HOME) and bridge tmp blobs. */
|
||||
var cacheDir: String = ""
|
||||
set(v) {
|
||||
field = v
|
||||
repoInit.cacheDir = v
|
||||
backupOp.cacheDir = v
|
||||
restoreOp.cacheDir = v
|
||||
snapshotOps.cacheDir = v
|
||||
maintenance.cacheDir = v
|
||||
}
|
||||
|
||||
/** Domain for SMB NTLM authentication. */
|
||||
var backendDomain: String
|
||||
get() = syncManager.backendDomain
|
||||
set(v) { syncManager.backendDomain = v }
|
||||
|
||||
/** Domain for SMB NTLM authentication. Propagated to sub-modules. */
|
||||
var backendDomain: String = ""
|
||||
set(v) {
|
||||
field = v
|
||||
repoInit.backendDomain = v
|
||||
backupOp.backendDomain = v
|
||||
restoreOp.backendDomain = v
|
||||
snapshotOps.backendDomain = v
|
||||
maintenance.backendDomain = v
|
||||
}
|
||||
// ── Progress data ─────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
@@ -81,6 +98,13 @@ object ResticWrapper {
|
||||
val hostname: String = ""
|
||||
)
|
||||
|
||||
/** App metadata read from a restic snapshot for change detection. */
|
||||
data class SnapshotAppInfo(
|
||||
val label: String,
|
||||
val isSystem: Boolean,
|
||||
val apkSizes: List<Long> = emptyList()
|
||||
)
|
||||
|
||||
// ── Repository lifecycle ─────────────────────────
|
||||
|
||||
suspend fun init(
|
||||
@@ -91,30 +115,28 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<Unit> = repoInit.init(
|
||||
repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
): AppResult<Unit> = repoInit.init(
|
||||
repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
|
||||
// ── Backup ─────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class BackupSummary(
|
||||
@SerialName("message_type") val messageType: String = "",
|
||||
@SerialName("snapshot_id") val snapshotId: String,
|
||||
@SerialName("files_new") val filesNew: Int,
|
||||
@SerialName("files_changed") val filesChanged: Int,
|
||||
@SerialName("files_unmodified") val filesUnmodified: Int,
|
||||
@SerialName("dirs_new") val dirsNew: Int,
|
||||
@SerialName("dirs_changed") val dirsChanged: Int,
|
||||
@SerialName("dirs_unmodified") val dirsUnmodified: Int,
|
||||
@SerialName("data_blobs") val dataBlobs: Int,
|
||||
@SerialName("tree_blobs") val treeBlobs: Int,
|
||||
@SerialName("data_added") val dataAdded: Long,
|
||||
@SerialName("total_files_processed") val totalFilesProcessed: Int,
|
||||
@SerialName("total_bytes_processed") val totalBytesProcessed: Long,
|
||||
@SerialName("total_duration") val totalDuration: Double
|
||||
@SerialName("files_new") val filesNew: Int = 0,
|
||||
@SerialName("files_changed") val filesChanged: Int = 0,
|
||||
@SerialName("files_unmodified") val filesUnmodified: Int = 0,
|
||||
@SerialName("dirs_new") val dirsNew: Int = 0,
|
||||
@SerialName("dirs_changed") val dirsChanged: Int = 0,
|
||||
@SerialName("dirs_unmodified") val dirsUnmodified: Int = 0,
|
||||
@SerialName("data_blobs") val dataBlobs: Int = 0,
|
||||
@SerialName("tree_blobs") val treeBlobs: Int = 0,
|
||||
@SerialName("data_added") val dataAdded: Long = 0,
|
||||
@SerialName("total_files_processed") val totalFilesProcessed: Int = 0,
|
||||
@SerialName("total_bytes_processed") val totalBytesProcessed: Long = 0,
|
||||
@SerialName("total_duration") val totalDuration: Double = 0.0
|
||||
)
|
||||
|
||||
suspend fun backup(
|
||||
@@ -128,13 +150,32 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
onProgress: suspend (ResticProgress) -> Unit = {}
|
||||
): Result<BackupSummary> = backupOp.backup(
|
||||
): AppResult<BackupSummary> = backupOp.backup(
|
||||
repoPath, password, paths, tags, hostname,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress, onProgress
|
||||
onProgress
|
||||
)
|
||||
|
||||
// ── Streaming backup (stdin) ─────────────────────
|
||||
|
||||
suspend fun backupStdin(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
stdinFile: File,
|
||||
extraPaths: List<String>,
|
||||
tags: List<String> = emptyList(),
|
||||
hostname: String? = null,
|
||||
backend: String = "local",
|
||||
backendUrl: String = "",
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onProgress: suspend (ResticProgress) -> Unit = {}
|
||||
): AppResult<BackupSummary> = backupOp.backupStdin(
|
||||
repoPath, password, stdinFile, extraPaths, tags, hostname,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onProgress
|
||||
)
|
||||
|
||||
// ── Restore ────────────────────────────────────────
|
||||
@@ -150,13 +191,11 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
onProgress: suspend (String) -> Unit = {}
|
||||
): Result<Unit> = restoreOp.restore(
|
||||
): AppResult<Unit> = restoreOp.restore(
|
||||
repoPath, password, snapshotId, targetPath, include,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress, onProgress
|
||||
onProgress
|
||||
)
|
||||
|
||||
// ── File dump ──────────────────────────────────────
|
||||
@@ -171,12 +210,9 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<String> = restoreOp.dump(
|
||||
): AppResult<String> = restoreOp.dump(
|
||||
repoPath, password, snapshotId, filePath,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
|
||||
// ── Snapshot management ────────────────────────────
|
||||
@@ -190,12 +226,9 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<List<ResticSnapshot>> = snapshotOps.listSnapshots(
|
||||
): AppResult<List<ResticSnapshot>> = snapshotOps.listSnapshots(
|
||||
repoPath, password, tag,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
|
||||
suspend fun forget(
|
||||
@@ -210,14 +243,81 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<String> = snapshotOps.forget(
|
||||
): AppResult<String> = snapshotOps.forget(
|
||||
repoPath, password, keepDaily, keepWeekly, keepMonthly, dryRun,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
|
||||
/**
|
||||
* Read [app_details.json] from the latest restic snapshot and return a map
|
||||
* of package-name → [SnapshotAppInfo]. Returns `null` when no snapshots
|
||||
* exist or the file cannot be read (e.g. first backup, legacy format).
|
||||
*/
|
||||
suspend fun getLatestSnapshotAppDetails(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
backend: String = "local",
|
||||
backendUrl: String = "",
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): Map<String, SnapshotAppInfo>? = withContext(Dispatchers.IO) {
|
||||
val snapsResult = snapshotOps.listSnapshots(
|
||||
repoPath, password, tag = null,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
val snaps = when (snapsResult) {
|
||||
is AppResult.Failure -> {
|
||||
Log.w(TAG, "getLatestSnapshotAppDetails: listSnapshots failed: ${snapsResult.error.message}")
|
||||
null
|
||||
}
|
||||
is AppResult.Success -> snapsResult.data
|
||||
} ?: return@withContext null
|
||||
|
||||
if (snaps.isEmpty()) return@withContext null
|
||||
|
||||
val latestId = snaps.first().shortId
|
||||
val basePath = snaps.first().paths.firstOrNull()?.trimEnd('/') ?: return@withContext null
|
||||
|
||||
val dumpResult = restoreOp.dump(
|
||||
repoPath, password, latestId, "$basePath/app_details.json",
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
|
||||
val jsonStr = when (dumpResult) {
|
||||
is AppResult.Failure -> return@withContext null
|
||||
is AppResult.Success -> dumpResult.data
|
||||
}
|
||||
|
||||
return@withContext parseAppDetailsJson(jsonStr)
|
||||
}
|
||||
|
||||
/** Parse [app_details.json] content into a package-name → [SnapshotAppInfo] map. */
|
||||
internal fun parseAppDetailsJson(jsonStr: String): Map<String, SnapshotAppInfo> {
|
||||
val map = mutableMapOf<String, SnapshotAppInfo>()
|
||||
try {
|
||||
val root = JSONObject(jsonStr)
|
||||
for (key in root.keys()) {
|
||||
val entry = root.optJSONObject(key) ?: continue
|
||||
val sizes = mutableListOf<Long>()
|
||||
val sizesArr = entry.optJSONArray("apkSizes")
|
||||
if (sizesArr != null) {
|
||||
for (i in 0 until sizesArr.length()) {
|
||||
sizes.add(sizesArr.optLong(i, 0L))
|
||||
}
|
||||
}
|
||||
map[key] = SnapshotAppInfo(
|
||||
label = entry.optString("label", key),
|
||||
isSystem = entry.optBoolean("isSystem", false),
|
||||
apkSizes = sizes
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
Log.w(TAG, "parseAppDetailsJson: failed to parse JSON")
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ── Maintenance ────────────────────────────────────
|
||||
|
||||
suspend fun prune(
|
||||
@@ -228,12 +328,9 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<String> = maintenance.prune(
|
||||
): AppResult<String> = maintenance.prune(
|
||||
repoPath, password,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
|
||||
suspend fun check(
|
||||
@@ -244,12 +341,9 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<String> = maintenance.check(
|
||||
): AppResult<String> = maintenance.check(
|
||||
repoPath, password,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
|
||||
suspend fun stats(
|
||||
@@ -260,12 +354,9 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): Result<String> = maintenance.stats(
|
||||
): AppResult<String> = maintenance.stats(
|
||||
repoPath, password,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
|
||||
// ── Public URL helper ──────────────────────────────
|
||||
@@ -274,14 +365,4 @@ object ResticWrapper {
|
||||
fun buildRepoUrl(backend: String, repoPath: String, backendUrl: String): String {
|
||||
return repoInit.buildRepoUrl(backend, repoPath, backendUrl)
|
||||
}
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Public safety-net cleanup called by fragment lifecycle.
|
||||
* Waits for any in-progress operation to finish, then deletes temp dirs.
|
||||
*/
|
||||
suspend fun cleanup() {
|
||||
syncManager.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -18,6 +20,8 @@ import kotlinx.serialization.Serializable
|
||||
*/
|
||||
object RestoreOperation {
|
||||
|
||||
private const val TAG = "RestoreOperation"
|
||||
|
||||
@Serializable
|
||||
data class RestoreProgress(
|
||||
val current: Int,
|
||||
@@ -39,6 +43,7 @@ object RestoreOperation {
|
||||
* @param filterPkgs if non-null, only restore packages in this set
|
||||
*/
|
||||
suspend fun restoreApps(
|
||||
context: Context,
|
||||
backupDir: File,
|
||||
userId: String = "0",
|
||||
filterPkgs: Set<String>? = null,
|
||||
@@ -47,6 +52,11 @@ object RestoreOperation {
|
||||
val emit: suspend (RestoreProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
// Resolve bundled binary paths for tar/zstd (backup used them, restore must too)
|
||||
val tarCmd = BinaryResolver.tarPath(context) ?: "tar"
|
||||
val bundledZstd = BinaryResolver.zstdPath(context)
|
||||
val zstdCmd = bundledZstd ?: "zstd"
|
||||
|
||||
// Read app list from backup
|
||||
val appListFile = File(backupDir, "appList.txt")
|
||||
val allPackages = if (appListFile.exists()) {
|
||||
@@ -66,6 +76,7 @@ object RestoreOperation {
|
||||
} else {
|
||||
allPackages
|
||||
}
|
||||
LogUtil.i(TAG, "restoreApps: starting restore of ${packages.size} packages from ${backupDir.absolutePath}")
|
||||
|
||||
val successAtomic = AtomicInteger(0)
|
||||
val failAtomic = AtomicInteger(0)
|
||||
@@ -84,7 +95,7 @@ object RestoreOperation {
|
||||
|
||||
// 1. Install APK
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "install", "正在安装 APK…"))
|
||||
val installed = installApk(appBackupDir)
|
||||
val installed = installApk(pkg, appBackupDir)
|
||||
|
||||
if (!installed) {
|
||||
failAtomic.incrementAndGet()
|
||||
@@ -97,11 +108,11 @@ object RestoreOperation {
|
||||
|
||||
// 3. Restore data
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "data", "正在恢复数据…"))
|
||||
restoreData(appBackupDir)
|
||||
restoreData(pkg, userId, appBackupDir, tarCmd, zstdCmd)
|
||||
|
||||
// 4. Restore OBB
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "obb", "正在恢复 OBB…"))
|
||||
restoreObb(pkg, appBackupDir)
|
||||
restoreObb(pkg, appBackupDir, tarCmd, zstdCmd)
|
||||
|
||||
// 5. Restore SSAID
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "ssaid", "正在恢复 SSAID…"))
|
||||
@@ -122,10 +133,13 @@ object RestoreOperation {
|
||||
}
|
||||
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
RestoreResult(successAtomic.get(), failAtomic.get(), elapsed)
|
||||
val successCount = successAtomic.get()
|
||||
val failCount = failAtomic.get()
|
||||
LogUtil.i(TAG, "restoreApps: completed — success=$successCount fail=$failCount elapsed=${elapsed}ms")
|
||||
RestoreResult(successCount, failCount, elapsed)
|
||||
}
|
||||
|
||||
private suspend fun installApk(appDir: File): Boolean {
|
||||
private suspend fun installApk(packageName: String, appDir: File): Boolean {
|
||||
// Find APK files
|
||||
val apkFiles = appDir.listFiles()
|
||||
?.filter { it.name.endsWith(".apk") }
|
||||
@@ -134,53 +148,132 @@ object RestoreOperation {
|
||||
|
||||
if (apkFiles.isEmpty()) return false
|
||||
|
||||
// Build install command for multiple APKs (split APK support)
|
||||
val apkPaths = apkFiles.joinToString(" ") { "'${it.absolutePath.shellEscape()}'" }
|
||||
suspend fun doInstall(): Boolean {
|
||||
// Build install command for multiple APKs (split APK support)
|
||||
val apkPaths = apkFiles.joinToString(" ") { "'${it.absolutePath.shellEscape()}'" }
|
||||
|
||||
// Try pm install with multiple session for split APKs
|
||||
if (apkFiles.size > 1) {
|
||||
val result = RootShell.exec("pm install-create -r -t 2>/dev/null")
|
||||
val sessionId = result.output.lines()
|
||||
.firstOrNull { it.contains("Success") }
|
||||
?.substringAfter("[")
|
||||
?.substringBefore("]")
|
||||
// Try pm install with multiple session for split APKs
|
||||
if (apkFiles.size > 1) {
|
||||
val result = RootShell.exec("pm install-create -r -t 2>/dev/null")
|
||||
val sessionId = result.output.lines()
|
||||
.firstOrNull { it.contains("Success") }
|
||||
?.substringAfter("[")
|
||||
?.substringBefore("]")
|
||||
|
||||
if (sessionId != null) {
|
||||
for ((i, apk) in apkFiles.withIndex()) {
|
||||
val sessionName = if (i == 0) "base.apk" else "split_${i}.apk"
|
||||
RootShell.exec("pm install-write '${sessionId.shellEscape()}' '$sessionName' '${apk.absolutePath.shellEscape()}'")
|
||||
if (sessionId != null) {
|
||||
for ((i, apk) in apkFiles.withIndex()) {
|
||||
val sessionName = if (i == 0) "base.apk" else "split_${i}.apk"
|
||||
RootShell.exec("pm install-write '${sessionId.shellEscape()}' '$sessionName' '${apk.absolutePath.shellEscape()}'")
|
||||
}
|
||||
val commit = RootShell.exec("pm install-commit '${sessionId.shellEscape()}'")
|
||||
return commit.isSuccess
|
||||
}
|
||||
val commit = RootShell.exec("pm install-commit '${sessionId.shellEscape()}'")
|
||||
return commit.isSuccess
|
||||
}
|
||||
|
||||
// Single APK install
|
||||
val result = RootShell.exec("pm install -r -t $apkPaths")
|
||||
return result.isSuccess
|
||||
}
|
||||
|
||||
// Single APK install
|
||||
val result = RootShell.exec("pm install -r -t $apkPaths")
|
||||
return result.isSuccess
|
||||
suspend fun isInstalled(): Boolean {
|
||||
val verifyResult = RootShell.exec("pm list packages '${packageName.shellEscape()}' 2>/dev/null")
|
||||
return verifyResult.output.contains(packageName)
|
||||
}
|
||||
|
||||
// First install attempt
|
||||
val firstOk = doInstall()
|
||||
if (!firstOk) {
|
||||
Log.e(TAG, "installApk: $packageName — first install attempt failed")
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify installation succeeded
|
||||
if (isInstalled()) {
|
||||
Log.i(TAG, "installApk: $packageName installed and verified")
|
||||
return true
|
||||
}
|
||||
|
||||
Log.w(TAG, "installApk: $packageName installed but not detected — retrying once")
|
||||
val retryOk = doInstall()
|
||||
if (!retryOk) {
|
||||
Log.e(TAG, "installApk: $packageName — retry install failed")
|
||||
return false
|
||||
}
|
||||
|
||||
if (isInstalled()) {
|
||||
Log.i(TAG, "installApk: $packageName installed and verified (after retry)")
|
||||
return true
|
||||
}
|
||||
|
||||
Log.e(TAG, "installApk: $packageName — install reported success but package not found after retry")
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun restoreData(appDir: File) {
|
||||
private suspend fun restoreData(packageName: String, userId: String, appDir: File, tarCmd: String, zstdCmd: String) {
|
||||
val files = appDir.listFiles()
|
||||
if (files.isNullOrEmpty()) {
|
||||
Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}")
|
||||
return
|
||||
}
|
||||
val dataFiles = files.filter { it.name.contains("_data.tar") }
|
||||
if (dataFiles.isEmpty()) {
|
||||
Log.w(TAG, "restoreData: no _data.tar in ${appDir.name}, found: ${files.map { it.name }}")
|
||||
return
|
||||
}
|
||||
|
||||
// Find data archive
|
||||
val dataFiles = appDir.listFiles()
|
||||
?.filter { it.name.contains("_data.tar") }
|
||||
?: return
|
||||
// Build exclusion patterns for cache/temp directories
|
||||
val dataPaths = listOf("/data/data/$packageName", "/data/user_de/$userId/$packageName")
|
||||
val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup")
|
||||
val excludeArgs = dataPaths.flatMap { dataPath ->
|
||||
excludeFolders.flatMap { folder ->
|
||||
listOf("--exclude='${dataPath.shellEscape()}/$folder'", "--exclude='${dataPath.shellEscape()}/$folder/*'")
|
||||
}
|
||||
}.joinToString(" ")
|
||||
|
||||
for (archive in dataFiles) {
|
||||
val archivePath = archive.absolutePath.shellEscape()
|
||||
// Verify archive doesn't contain path traversal before extracting
|
||||
if (!isArchiveSafe(archive)) continue
|
||||
when {
|
||||
archive.name.endsWith(".zst") -> {
|
||||
RootShell.exec("zstd -d -c '$archivePath' | tar -xf - -C / 2>/dev/null")
|
||||
}
|
||||
archive.name.endsWith(".gz") -> {
|
||||
RootShell.exec("tar -xzf '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
archive.name.endsWith(".tar") -> {
|
||||
RootShell.exec("tar -xf '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
Log.d(TAG, "restoreData: found archive ${archive.name}")
|
||||
if (!isArchiveSafe(archive, zstdCmd)) {
|
||||
Log.w(TAG, "restoreData: archive NOT SAFE, skipping: ${archive.name}")
|
||||
continue
|
||||
}
|
||||
|
||||
// Build the extract command with exclusion flags
|
||||
val baseCmd = when {
|
||||
archive.name.endsWith(".zst") ->
|
||||
"set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null"
|
||||
archive.name.endsWith(".gz") ->
|
||||
"$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null"
|
||||
archive.name.endsWith(".tar") ->
|
||||
"$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null"
|
||||
else -> { Log.w(TAG, "restoreData: unknown archive type ${archive.name}"); continue }
|
||||
}
|
||||
|
||||
val result = RootShell.exec(baseCmd)
|
||||
if (result.isSuccess) {
|
||||
Log.i(TAG, "restoreData: extracted ${archive.name}")
|
||||
} else {
|
||||
Log.e(TAG, "restoreData: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}")
|
||||
// Continue to try SELinux fix even if extraction had issues
|
||||
}
|
||||
}
|
||||
|
||||
// Restore SELinux context on extracted data directories
|
||||
for (dataPath in dataPaths) {
|
||||
// Try to get the existing context (if the path already existed)
|
||||
val existingContext = SELinuxUtil.getContext(dataPath)
|
||||
val context = existingContext ?: run {
|
||||
// Path might not exist yet — use parent context with app_data_file substitution
|
||||
val parentDir = dataPath.substringBeforeLast("/")
|
||||
val parentContext = SELinuxUtil.getContext(parentDir)
|
||||
parentContext?.replace("system_data_file", "app_data_file")
|
||||
}
|
||||
|
||||
if (context != null) {
|
||||
Log.d(TAG, "restoreData: restoring SELinux context on $dataPath: $context")
|
||||
SELinuxUtil.chcon(context, dataPath)
|
||||
} else {
|
||||
Log.w(TAG, "restoreData: could not determine SELinux context for $dataPath")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,13 +283,18 @@ object RestoreOperation {
|
||||
* or symbolic links pointing outside the tree.
|
||||
* Accepts both absolute and relative paths — tar implementations vary.
|
||||
*/
|
||||
private suspend fun isArchiveSafe(archive: File): Boolean {
|
||||
private suspend fun isArchiveSafe(archive: File, zstdCmd: String = "zstd"): Boolean {
|
||||
val listCmd = if (archive.name.endsWith(".zst")) {
|
||||
"zstd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null"
|
||||
"set -o pipefail; $zstdCmd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null"
|
||||
} else {
|
||||
"tar tf '${archive.absolutePath.shellEscape()}' 2>/dev/null"
|
||||
}
|
||||
val result = RootShell.exec(listCmd)
|
||||
var result = RootShell.exec(listCmd)
|
||||
// Fallback: try without pipefail (some Android shells don't support it)
|
||||
if (!result.isSuccess && archive.name.endsWith(".zst")) {
|
||||
val fallbackCmd = "$zstdCmd -d -c '${archive.absolutePath.shellEscape()}' 2>/dev/null | tar tf - 2>/dev/null"
|
||||
result = RootShell.exec(fallbackCmd)
|
||||
}
|
||||
if (!result.isSuccess) return false
|
||||
return !result.output.lines().any { line ->
|
||||
val path = line.substringBefore(" -> ")
|
||||
@@ -208,77 +306,50 @@ object RestoreOperation {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreObb(packageName: String, appDir: File) {
|
||||
private suspend fun restoreObb(packageName: String, appDir: File, tarCmd: String, zstdCmd: String) {
|
||||
val obbFiles = appDir.listFiles()
|
||||
?.filter { it.name.contains("_obb.tar") }
|
||||
?: return
|
||||
|
||||
if (obbFiles.isEmpty()) return
|
||||
|
||||
// Build exclusion patterns for OBB cache/temp directories
|
||||
val obbPath = "/storage/emulated/0/Android/obb/$packageName"
|
||||
val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup", "Backup_*")
|
||||
val excludeArgs = excludeFolders.joinToString(" ") { "--exclude='${obbPath.shellEscape()}/$it' --exclude='${obbPath.shellEscape()}/$it/*'" }
|
||||
|
||||
for (archive in obbFiles) {
|
||||
if (!isArchiveSafe(archive)) continue
|
||||
if (!isArchiveSafe(archive, zstdCmd)) continue
|
||||
val archivePath = archive.absolutePath.shellEscape()
|
||||
when {
|
||||
archive.name.endsWith(".zst") -> {
|
||||
RootShell.exec("zstd -d -c '$archivePath' | tar -xf - -C / 2>/dev/null")
|
||||
RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null")
|
||||
}
|
||||
archive.name.endsWith(".gz") -> {
|
||||
RootShell.exec("tar -xzf '$archivePath' -C / 2>/dev/null")
|
||||
RootShell.exec("$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
archive.name.endsWith(".tar") -> {
|
||||
RootShell.exec("tar -xf '$archivePath' -C / 2>/dev/null")
|
||||
RootShell.exec("$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fix OBB permissions
|
||||
RootShell.exec("chown -R 1023:1023 /storage/emulated/0/Android/obb/${packageName.shellEscape()}/ 2>/dev/null")
|
||||
// Fix OBB permissions: resolve GID from parent directory instead of hardcoding 1023
|
||||
val gidResult = RootShell.exec("stat -c %g '${obbPath.shellEscape()}' 2>/dev/null")
|
||||
val gid = gidResult.output.trim().toIntOrNull() ?: 1023 // fallback to media_rw gid
|
||||
RootShell.exec("chown -R $gid:$gid '${obbPath.shellEscape()}/' 2>/dev/null")
|
||||
Log.i(TAG, "restoreObb: set ownership to $gid:$gid on $obbPath")
|
||||
}
|
||||
|
||||
private suspend fun restoreSsaid(packageName: String, appDir: File, userId: String) {
|
||||
val ssaidFile = File(appDir, "ssaid.txt")
|
||||
if (!ssaidFile.exists()) return
|
||||
|
||||
val ssaidLine = ssaidFile.readText().trim()
|
||||
if (ssaidLine.isBlank()) return
|
||||
val ssaidValue = ssaidFile.readText().trim()
|
||||
if (ssaidValue.isBlank()) return
|
||||
|
||||
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
val ssaidEsc = ssaidLine.shellEscape()
|
||||
|
||||
// Remove existing entry for this package, insert new one before </settings>
|
||||
RootShell.exec(
|
||||
"grep -v '${pkgEsc}' '$targetFile' > '$targetFile.tmp' && " +
|
||||
"sed -i '\$ i ${ssaidEsc}' '$targetFile.tmp' && " +
|
||||
"mv '$targetFile.tmp' '$targetFile'"
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun restorePermissions(packageName: String, appDir: File) {
|
||||
val permFile = File(appDir, "permissions.txt")
|
||||
if (!permFile.exists()) return
|
||||
|
||||
val perms = permFile.readLines()
|
||||
.filter { it.contains("granted=true") }
|
||||
.mapNotNull { line ->
|
||||
// Extract permission name from dumpsys output
|
||||
// Format: "permission.name: granted=true" or similar
|
||||
line.substringBefore(":")
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() && it.contains(".") }
|
||||
}
|
||||
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
for (perm in perms) {
|
||||
val result = RootShell.exec("pm grant '$pkgEsc' '${perm.shellEscape()}' 2>&1")
|
||||
if (!result.isSuccess) {
|
||||
android.util.Log.w("RestoreOperation", "pm grant failed for $packageName: $perm — ${result.output}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fixDataOwnership(packageName: String, userId: String) {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
val uidEsc = userId.shellEscape()
|
||||
val uidResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1")
|
||||
// Resolve the app's UID
|
||||
val uidResult = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep 'userId=' | head -1")
|
||||
val uid = uidResult.output
|
||||
.substringAfter("userId=", "")
|
||||
.substringBefore(" ")
|
||||
@@ -286,11 +357,170 @@ object RestoreOperation {
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
|
||||
if (uid != null) {
|
||||
RootShell.exec("chown -R $uid:$uid /data/data/$pkgEsc/ 2>/dev/null")
|
||||
RootShell.exec("chown -R $uid:$uid /data/user_de/$uidEsc/$pkgEsc/ 2>/dev/null")
|
||||
RootShell.exec("restorecon -R /data/data/$pkgEsc/ 2>/dev/null")
|
||||
RootShell.exec("restorecon -R /data/user_de/$uidEsc/$pkgEsc/ 2>/dev/null")
|
||||
if (uid == null) {
|
||||
Log.w(TAG, "restoreSsaid: could not resolve UID for $packageName")
|
||||
return
|
||||
}
|
||||
|
||||
// Try XML-based approach first (more reliable across Android versions)
|
||||
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
|
||||
val xmlSuccess = run {
|
||||
// Check if file exists
|
||||
val checkResult = RootShell.exec("test -f '$targetFile' && echo 'exists'")
|
||||
if (!checkResult.output.contains("exists")) {
|
||||
Log.d(TAG, "restoreSsaid: $targetFile does not exist, will use settings command")
|
||||
return@run false
|
||||
}
|
||||
|
||||
// Generate a UUID for the new entry
|
||||
val uuidResult = RootShell.exec("cat /proc/sys/kernel/random/uuid 2>/dev/null")
|
||||
val id = uuidResult.output.trim()
|
||||
if (id.length != 36) { // UUID format check
|
||||
Log.w(TAG, "restoreSsaid: could not generate UUID (got '$id'), falling back")
|
||||
return@run false
|
||||
}
|
||||
|
||||
// Remove existing entry for this package and insert new one before </settings>
|
||||
val manipCmd = buildString {
|
||||
append("sed -i \"/package.*${packageName.shellEscape()}/d\" '$targetFile' && ")
|
||||
append("sed -i \"s#</settings>#<setting id=\\\"$id\\\" package=\\\"${packageName.shellEscape()}\\\" value=\\\"${ssaidValue.shellEscape()}\\\" defaultValue=\\\"default\\\" />\\n</settings>#\" '$targetFile'")
|
||||
}
|
||||
val result = RootShell.exec(manipCmd)
|
||||
if (!result.isSuccess) {
|
||||
Log.w(TAG, "restoreSsaid: XML edit failed: ${result.error}")
|
||||
return@run false
|
||||
}
|
||||
|
||||
// Verify the package entry was added by checking if it appears in the file now
|
||||
val verifyCmd = RootShell.exec("grep -c \"${packageName.shellEscape()}\" '$targetFile' 2>/dev/null")
|
||||
val entryCount = verifyCmd.output.trim().toIntOrNull() ?: 0
|
||||
if (entryCount > 0) {
|
||||
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via XML (uid=$uid)")
|
||||
true
|
||||
} else {
|
||||
Log.w(TAG, "restoreSsaid: XML edit completed but entry not found, falling back")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use settings put secure if XML approach failed
|
||||
if (!xmlSuccess) {
|
||||
val result = RootShell.exec("settings put secure ssaid_$uid '${ssaidValue.shellEscape()}'")
|
||||
if (result.isSuccess) {
|
||||
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via settings (uid=$uid)")
|
||||
} else {
|
||||
Log.e(TAG, "restoreSsaid: failed to set SSAID for $packageName: ${result.error}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restorePermissions(packageName: String, appDir: File) {
|
||||
val permFile = File(appDir, "permissions.txt")
|
||||
if (!permFile.exists()) return
|
||||
|
||||
// Parse permissions from dumpsys output.
|
||||
// Format: "android.permission.XXX: granted=true" or "android.permission.XXX: granted=false"
|
||||
val parsedPerms = try {
|
||||
permFile.readLines().mapNotNull { line ->
|
||||
val name = line.substringBefore(":").trim().takeIf { it.isNotEmpty() && it.contains(".") } ?: return@mapNotNull null
|
||||
val granted = line.contains("granted=true")
|
||||
Pair(name, granted)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
|
||||
if (parsedPerms.isEmpty()) return
|
||||
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
|
||||
// Reset app ops first (clears any previous modes)
|
||||
RootShell.exec("appops reset '$pkgEsc' 2>/dev/null")
|
||||
|
||||
val grantedPerms = parsedPerms.filter { it.second }.map { it.first }
|
||||
val deniedPerms = parsedPerms.filter { !it.second }.map { it.first }
|
||||
|
||||
// Grant runtime permissions that were previously granted
|
||||
for (perm in grantedPerms) {
|
||||
val result = RootShell.exec("pm grant '$pkgEsc' '${perm.shellEscape()}' 2>&1")
|
||||
if (!result.isSuccess) {
|
||||
Log.w(TAG, "restorePermissions: pm grant failed for $packageName: $perm — ${result.output}")
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke runtime permissions that were explicitly denied
|
||||
for (perm in deniedPerms) {
|
||||
val result = RootShell.exec("pm revoke '$pkgEsc' '${perm.shellEscape()}' 2>&1")
|
||||
if (!result.isSuccess) {
|
||||
// Revoking a permission that isn't granted is not an error — just log at debug level
|
||||
Log.d(TAG, "restorePermissions: pm revoke for $packageName: $perm — ${result.output}")
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "restorePermissions: ${grantedPerms.size} granted, ${deniedPerms.size} revoked for $packageName")
|
||||
}
|
||||
|
||||
/** Resolve app UID using multiple methods for robustness across Android versions. */
|
||||
private suspend fun resolveAppUid(packageName: String): Int? {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
// Method 1: pm list packages -U (reliable, consistent output format)
|
||||
val pmResult = RootShell.exec("pm list packages -U 2>/dev/null | grep '${pkgEsc}$'")
|
||||
val pmUid = pmResult.output
|
||||
.substringAfter(" uid:")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
if (pmUid != null) return pmUid
|
||||
|
||||
// Method 2: dumpsys package (fallback for older Android)
|
||||
val dsResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1")
|
||||
val dsUid = dsResult.output
|
||||
.substringAfter("userId=", "")
|
||||
.substringBefore(" ")
|
||||
.substringBefore(",")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
if (dsUid != null) return dsUid
|
||||
|
||||
// Method 3: dumpsys with userId: separator (AOSP variant)
|
||||
val ds2Result = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId:' | head -1")
|
||||
val ds2Uid = ds2Result.output
|
||||
.substringAfter("userId:", "")
|
||||
.substringBefore(" ")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
return ds2Uid
|
||||
}
|
||||
|
||||
private suspend fun fixDataOwnership(packageName: String, userId: String) {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
val uidEsc = userId.shellEscape()
|
||||
|
||||
val uid = resolveAppUid(packageName)
|
||||
if (uid == null) {
|
||||
Log.w(TAG, "fixDataOwnership: could not resolve UID for $packageName — data will be inaccessible")
|
||||
return
|
||||
}
|
||||
|
||||
// USER and USER_DE use uid:uid (app's own group)
|
||||
val dataPaths = listOf(
|
||||
"/data/data/$pkgEsc",
|
||||
"/data/user_de/$uidEsc/$pkgEsc"
|
||||
)
|
||||
|
||||
for (dataPath in dataPaths) {
|
||||
RootShell.exec("chown -R $uid:$uid '$dataPath/' 2>/dev/null")
|
||||
|
||||
// Restore SELinux context instead of using restorecon (which applies defaults)
|
||||
val existingContext = SELinuxUtil.getContext(dataPath)
|
||||
val context = existingContext ?: run {
|
||||
val parentDir = dataPath.substringBeforeLast("/")
|
||||
val parentContext = SELinuxUtil.getContext(parentDir)
|
||||
parentContext?.replace("system_data_file", "app_data_file")
|
||||
}
|
||||
if (context != null) {
|
||||
SELinuxUtil.chcon(context, dataPath)
|
||||
Log.d(TAG, "fixDataOwnership: restored SELinux context on $dataPath: $context")
|
||||
} else {
|
||||
Log.w(TAG, "fixDataOwnership: could not determine SELinux context for $dataPath")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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: $remotePath — ntStatus=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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@ import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Backup and restore WiFi configuration.
|
||||
* Mirrors backup_script WiFi backup/restore logic.
|
||||
*/
|
||||
object WifiManager {
|
||||
private const val TAG = "WifiManager"
|
||||
|
||||
|
||||
// Possible WiFi config paths on different Android versions
|
||||
private val WIFI_PATHS = listOf(
|
||||
@@ -57,21 +60,27 @@ object WifiManager {
|
||||
// Try the most common path
|
||||
val fallback = "/data/misc/apexdata/com.android.wifi/WifiConfigStore.xml"
|
||||
val parent = File(fallback).parentFile?.absolutePath?.shellEscape() ?: return@withContext false
|
||||
RootShell.exec("mkdir -p '$parent'")
|
||||
val mkdirResult = RootShell.exec("mkdir -p '$parent'")
|
||||
if (!mkdirResult.isSuccess) return@withContext false
|
||||
val result = RootShell.exec("cp '$backupPath' '$fallback'")
|
||||
if (!result.isSuccess) return@withContext false
|
||||
RootShell.exec("chown system:wifi '$fallback'")
|
||||
RootShell.exec("chmod 0660 '$fallback'")
|
||||
val chownResult = RootShell.exec("chown system:wifi '$fallback'")
|
||||
if (!chownResult.isSuccess) Log.w(TAG, "chown failed: ${chownResult.error}")
|
||||
val chmodResult = RootShell.exec("chmod 0660 '$fallback'")
|
||||
if (!chmodResult.isSuccess) Log.w(TAG, "chmod failed: ${chmodResult.error}")
|
||||
} else {
|
||||
val result = RootShell.exec("cp '$backupPath' '$wifiTarget'")
|
||||
if (!result.isSuccess) return@withContext false
|
||||
RootShell.exec("chown system:wifi '$wifiTarget'")
|
||||
RootShell.exec("chmod 0660 '$wifiTarget'")
|
||||
val chownResult = RootShell.exec("chown system:wifi '$wifiTarget'")
|
||||
if (!chownResult.isSuccess) Log.w(TAG, "chown failed: ${chownResult.error}")
|
||||
val chmodResult = RootShell.exec("chmod 0660 '$wifiTarget'")
|
||||
if (!chmodResult.isSuccess) Log.w(TAG, "chmod failed: ${chmodResult.error}")
|
||||
}
|
||||
|
||||
// WiFi backup only takes effect after reboot, but we can try reloading
|
||||
RootShell.exec("svc wifi disable 2>/dev/null")
|
||||
RootShell.exec("svc wifi enable 2>/dev/null")
|
||||
// These are best-effort since reloading WiFi only takes full effect on reboot
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package com.example.androidbackupgui.root
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.*
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.io.InputStream
|
||||
import android.util.Log
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
/**
|
||||
* Escape a string for safe use inside single-quoted shell strings.
|
||||
@@ -15,23 +15,16 @@ import android.util.Log
|
||||
fun String.shellEscape(): String = this.replace("'", "'\\''")
|
||||
|
||||
/**
|
||||
* Persistent root shell session via `su`.
|
||||
* Manages a single su process and executes commands sequentially.
|
||||
* Thread-safe via Mutex — all session state is guarded by the mutex.
|
||||
* Root shell access via libsu.
|
||||
* Shell.cmd internally manages su sessions, compatible with Magisk/KernelSU/APatch.
|
||||
* All shell operations are thread-safe through coroutine dispatchers.
|
||||
*/
|
||||
object RootShell {
|
||||
|
||||
private var process: Process? = null
|
||||
private var writer: OutputStreamWriter? = null
|
||||
private var reader: BufferedReader? = null
|
||||
private var errReader: BufferedReader? = null
|
||||
|
||||
private const val TAG = "RootShell"
|
||||
/** Default command timeout in milliseconds. */
|
||||
private const val COMMAND_TIMEOUT_MS = 120_000L
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
/** Result of a shell command execution. */
|
||||
data class ShellResult(
|
||||
val output: String,
|
||||
@@ -41,134 +34,69 @@ object RootShell {
|
||||
val isSuccess get() = exitCode == 0
|
||||
}
|
||||
|
||||
/** Quick process-alive check. Caller MUST hold the mutex. */
|
||||
private fun isAliveUnsafe(): Boolean {
|
||||
val p = process ?: return false
|
||||
return try { p.exitValue(); false } catch (_: IllegalThreadStateException) { true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Open (or re-open) the su session and verify root access.
|
||||
* Caller MUST hold the mutex.
|
||||
* libsu shell initializer: enter global mount namespace via nsenter.
|
||||
* Preserves the original PATH so that tar/zstd (from Termux etc.) remain accessible.
|
||||
* Ref: DataBackup (XayahSuSuSu) uses the same nsenter pattern.
|
||||
*/
|
||||
private fun ensureSessionUnsafe(): Boolean {
|
||||
if (isAliveUnsafe()) return true
|
||||
return try {
|
||||
val p = Runtime.getRuntime().exec(arrayOf("su"))
|
||||
writer = OutputStreamWriter(p.outputStream)
|
||||
reader = BufferedReader(InputStreamReader(p.inputStream))
|
||||
errReader = BufferedReader(InputStreamReader(p.errorStream))
|
||||
process = p
|
||||
// Drain stderr in background to prevent pipe-buffer deadlock
|
||||
Thread({
|
||||
try { while (errReader?.readLine() != null) {} } catch (_: Exception) {}
|
||||
}, "su-stderr-drain").apply { isDaemon = true; start() }
|
||||
// Inline verification — cannot call exec() which would deadlock on mutex
|
||||
val sentinel = "ROOT_OK_${System.nanoTime()}"
|
||||
writer?.write("echo $sentinel\n"); writer?.flush()
|
||||
var line: String?
|
||||
while (reader?.readLine().also { line = it } != null) {
|
||||
if (line!!.contains(sentinel)) return true
|
||||
}
|
||||
false
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
private class GlobalNamespaceInitializer : Shell.Initializer() {
|
||||
override fun onInit(context: android.content.Context, shell: Shell): Boolean {
|
||||
shell.newJob()
|
||||
.add("nsenter --mount=/proc/1/ns/mnt sh")
|
||||
.add("set -o pipefail")
|
||||
.exec()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure a root shell is open. Returns true if root is available. */
|
||||
suspend fun ensureSession(): Boolean = mutex.withLock {
|
||||
ensureSessionUnsafe()
|
||||
/** Call once at app startup to configure libsu. */
|
||||
fun configure() {
|
||||
Shell.enableVerboseLogging = true
|
||||
Shell.setDefaultBuilder(
|
||||
Shell.Builder.create()
|
||||
.setFlags(Shell.FLAG_MOUNT_MASTER)
|
||||
.setInitializers(GlobalNamespaceInitializer::class.java)
|
||||
.setTimeout(30)
|
||||
)
|
||||
}
|
||||
|
||||
/** Cleanup all session state. Caller MUST hold the mutex. */
|
||||
private fun closeUnsafe() {
|
||||
try { writer?.close() } catch (_: Exception) {}
|
||||
try { reader?.close() } catch (_: Exception) {}
|
||||
try { errReader?.close() } catch (_: Exception) {}
|
||||
try { process?.destroy() } catch (_: Exception) {}
|
||||
process = null
|
||||
writer = null
|
||||
reader = null
|
||||
errReader = null
|
||||
}
|
||||
|
||||
/** Close the root shell session. */
|
||||
suspend fun close() = mutex.withLock {
|
||||
closeUnsafe()
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and return the output.
|
||||
* Uses a sentinel delimiter to identify end of output.
|
||||
* Timeout is enforced via structured coroutine cancellation:
|
||||
* `withTimeout(timeoutMs)` cancels the coroutine, interrupting the
|
||||
* blocking readLine() on Dispatchers.IO. If the process cannot be
|
||||
* interrupted, closeUnsafe() destroys it in the catch handler.
|
||||
*/
|
||||
suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult = mutex.withLock {
|
||||
if (!isAliveUnsafe() && !ensureSessionUnsafe()) {
|
||||
return@exec ShellResult("", "No root access", -1)
|
||||
}
|
||||
|
||||
val sentinel = "EXIT_${System.nanoTime()}"
|
||||
writer?.write("$command; echo $sentinel \$?\n")
|
||||
writer?.flush()
|
||||
|
||||
suspend fun ensureSession(): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
withTimeout(timeoutMs) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val output = StringBuilder()
|
||||
var line: String?
|
||||
while (reader?.readLine().also { line = it } != null) {
|
||||
val l = line!!
|
||||
if (l.startsWith(sentinel)) {
|
||||
val code = l.removePrefix("$sentinel ").trim().toIntOrNull() ?: -1
|
||||
return@withContext ShellResult(output.toString().trimEnd(), "", code)
|
||||
}
|
||||
output.appendLine(l)
|
||||
}
|
||||
// Process destroyed or readLine returned null naturally
|
||||
ShellResult(output.toString().trimEnd(), "", -1)
|
||||
}
|
||||
}
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
Log.w(TAG, "exec timeout (${timeoutMs}ms) destroying process: $command")
|
||||
closeUnsafe()
|
||||
ShellResult("", "Command timed out after ${timeoutMs}ms", -1)
|
||||
}
|
||||
Shell.getShell().isRoot
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
ensureActive()
|
||||
try {
|
||||
val result = withTimeout(timeoutMs) {
|
||||
Shell.cmd(command).exec()
|
||||
}
|
||||
ShellResult(
|
||||
output = result.out.joinToString("\n"),
|
||||
error = result.err.joinToString("\n"),
|
||||
exitCode = result.code,
|
||||
)
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
Log.w(TAG, "exec timeout (${timeoutMs}ms): $command")
|
||||
ShellResult("", "Command timed out after ${timeoutMs}ms", -1)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "exec failed: $command", e)
|
||||
ShellResult("", e.message ?: "Unknown error", -1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command via `su` and return the stdout as an InputStream
|
||||
* for binary-safe streaming. Caller MUST close the stream and call
|
||||
* waitForStreamResult() or destroy the returned process.
|
||||
* 安全执行 root shell 命令,自动 shellEscape 每个参数。
|
||||
* @param parts 命令和参数列表,第一个元素是命令本身
|
||||
* @param timeoutMs 超时毫秒
|
||||
*/
|
||||
class StreamProcess(
|
||||
val process: Process,
|
||||
val inputStream: InputStream,
|
||||
private val command: String
|
||||
) {
|
||||
fun waitFor(): Int {
|
||||
try { process.waitFor() } catch (_: Exception) {}
|
||||
return process.exitValue()
|
||||
}
|
||||
fun destroy() {
|
||||
try { process.destroy() } catch (_: Exception) {}
|
||||
try { inputStream.close() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
fun execBinary(command: String): StreamProcess? {
|
||||
return try {
|
||||
val p = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
|
||||
// Drain stderr to prevent pipe deadlock
|
||||
Thread({
|
||||
try { p.errorStream.use { it.readBytes() } } catch (_: Exception) {}
|
||||
}, "su-binary-stderr").apply { isDaemon = true }.start()
|
||||
StreamProcess(p, p.inputStream, command)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
suspend fun execSafe(
|
||||
parts: List<String>,
|
||||
timeoutMs: Long = COMMAND_TIMEOUT_MS
|
||||
): ShellResult = exec(
|
||||
command = parts.joinToString(" ") { "'${it.shellEscape()}'" },
|
||||
timeoutMs = timeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import androidx.core.content.ContextCompat
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.example.androidbackupgui.backup.AppInfo
|
||||
import com.example.androidbackupgui.backup.PackageName
|
||||
import com.example.androidbackupgui.backup.AppScanner
|
||||
import com.example.androidbackupgui.backup.BackupConfig
|
||||
import com.example.androidbackupgui.backup.BackupOperation
|
||||
import com.example.androidbackupgui.backup.BackupService
|
||||
import com.example.androidbackupgui.backup.ResticBinary
|
||||
import com.example.androidbackupgui.backup.ResticWrapper
|
||||
import com.example.androidbackupgui.backup.WifiManager
|
||||
import com.example.androidbackupgui.backup.RemoteTransport
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.databinding.FragmentBackupBinding
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import android.os.StatFs
|
||||
import com.example.androidbackupgui.backup.StreamingBackup
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import com.example.androidbackupgui.backup.formatSize
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
@@ -28,7 +41,15 @@ class BackupFragment : Fragment() {
|
||||
private val binding get() = _binding!!
|
||||
private var apps: List<AppInfo> = emptyList()
|
||||
private var selectedApps = mutableSetOf<String>()
|
||||
private var sortedApps: List<AppInfo> = emptyList()
|
||||
private lateinit var config: BackupConfig
|
||||
private var selectedUserId: Int = 0
|
||||
private var userList: List<Pair<Int, String>> = listOf(0 to "Owner")
|
||||
private var sortMode: SortMode = SortMode.NAME_ASC
|
||||
private var showSystemApps: Boolean = false
|
||||
private var excludeDataFromBackup = mutableSetOf<String>()
|
||||
|
||||
private enum class SortMode { NAME_ASC, SIZE_DESC }
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
@@ -42,11 +63,67 @@ class BackupFragment : Fragment() {
|
||||
|
||||
val configFile = File(requireContext().filesDir, "backup_settings.conf")
|
||||
config = BackupConfig.fromFile(configFile)
|
||||
updateOutputPathDisplay()
|
||||
|
||||
binding.appList.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
||||
binding.scanButton.setOnClickListener { scanApps() }
|
||||
binding.outputPathEdit.setOnClickListener { showOutputPathEditDialog() }
|
||||
binding.backupButton.setOnClickListener { startBackup() }
|
||||
|
||||
// Sort/filter controls
|
||||
binding.sortAZButton.setOnClickListener {
|
||||
sortMode = SortMode.NAME_ASC
|
||||
applySortFilter()
|
||||
}
|
||||
binding.sortSizeButton.setOnClickListener {
|
||||
sortMode = SortMode.SIZE_DESC
|
||||
applySortFilter()
|
||||
}
|
||||
binding.selectAllButton.setOnClickListener {
|
||||
selectedApps.addAll(apps.map { it.packageName.value })
|
||||
applySortFilter()
|
||||
}
|
||||
binding.deselectAllButton.setOnClickListener {
|
||||
selectedApps.clear()
|
||||
applySortFilter()
|
||||
}
|
||||
binding.showSystemSwitch.setOnCheckedChangeListener { _, checked ->
|
||||
showSystemApps = checked
|
||||
applySortFilter()
|
||||
}
|
||||
|
||||
// Load user profiles and setup dropdown
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
private fun loadUsers() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
userList = AppScanner.enumerateUsers()
|
||||
val names = userList.map { (id, name) -> "$name (ID: $id)" }
|
||||
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names)
|
||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
binding.userSelector.adapter = adapter
|
||||
binding.userSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
selectedUserId = userList.getOrNull(position)?.first ?: 0
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
binding.statusText.text = "加载用户失败: ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (::config.isInitialized) {
|
||||
val configFile = File(requireContext().filesDir, "backup_settings.conf")
|
||||
config = BackupConfig.fromFile(configFile)
|
||||
updateOutputPathDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scanApps() {
|
||||
@@ -55,153 +132,416 @@ class BackupFragment : Fragment() {
|
||||
binding.statusText.text = "正在扫描应用…"
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val ctx = requireContext()
|
||||
val thirdParty = AppScanner.scanThirdParty(ctx)
|
||||
val system = AppScanner.scanSystem(ctx, config)
|
||||
apps = thirdParty + system
|
||||
selectedApps.clear()
|
||||
selectedApps.addAll(apps.map { it.packageName })
|
||||
try {
|
||||
val ctx = requireContext()
|
||||
val thirdParty = AppScanner.scanThirdParty(ctx, userId = selectedUserId)
|
||||
val system = AppScanner.scanSystem(ctx, config, userId = selectedUserId)
|
||||
apps = if (showSystemApps) thirdParty + system else thirdParty
|
||||
selectedApps.clear()
|
||||
selectedApps.addAll(apps.map { it.packageName.value })
|
||||
|
||||
binding.statusText.text = "共找到 ${apps.size} 个应用,全部已选中"
|
||||
binding.backupButton.isEnabled = apps.isNotEmpty()
|
||||
setRunning(false)
|
||||
binding.statusText.text = "共找到 ${apps.size} 个应用,全部已选中"
|
||||
binding.backupButton.isEnabled = apps.isNotEmpty()
|
||||
setRunning(false)
|
||||
|
||||
setupAppList()
|
||||
applySortFilter()
|
||||
} catch (e: Exception) {
|
||||
binding.statusText.text = "扫描应用失败: ${e.message}"
|
||||
setRunning(false)
|
||||
binding.backupButton.isEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupAppList() {
|
||||
binding.appList.adapter = PackageListAdapter(apps, selectedApps) { pkg, checked ->
|
||||
if (checked) selectedApps.add(pkg) else selectedApps.remove(pkg)
|
||||
binding.statusText.text = "已选择 ${selectedApps.size}/${apps.size} 个应用"
|
||||
private fun applySortFilter() {
|
||||
var filtered = if (showSystemApps) apps else apps.filter { !it.isSystem }
|
||||
filtered = when (sortMode) {
|
||||
SortMode.NAME_ASC -> filtered.sortedBy { it.label.lowercase(Locale.US) }
|
||||
SortMode.SIZE_DESC -> filtered.sortedByDescending { it.backupSize }
|
||||
}
|
||||
sortedApps = filtered
|
||||
setupAppList()
|
||||
binding.statusText.text = "已选择 ${selectedApps.size}/${sortedApps.size} 个应用"
|
||||
}
|
||||
private fun setupAppList() {
|
||||
val displayApps = sortedApps.ifEmpty { apps }
|
||||
binding.appList.adapter = PackageListAdapter(
|
||||
displayApps, selectedApps,
|
||||
onToggle = { pkg, checked ->
|
||||
if (checked) selectedApps.add(pkg) else selectedApps.remove(pkg)
|
||||
binding.statusText.text = "已选择 ${selectedApps.size}/${displayApps.size} 个应用"
|
||||
},
|
||||
excludeDataFrom = excludeDataFromBackup,
|
||||
onExcludeDataToggle = { pkg, excluded ->
|
||||
if (excluded) excludeDataFromBackup.add(pkg) else excludeDataFromBackup.remove(pkg)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun startBackup() {
|
||||
val toBackup = apps.filter { it.packageName in selectedApps }
|
||||
val toBackup = apps.filter { it.packageName.value in selectedApps }
|
||||
if (toBackup.isEmpty()) return
|
||||
|
||||
setRunning(true)
|
||||
binding.backupButton.isEnabled = false
|
||||
binding.scanButton.isEnabled = false
|
||||
|
||||
// Start foreground service to keep process alive
|
||||
val serviceIntent = Intent(requireContext(), BackupService::class.java)
|
||||
serviceIntent.action = BackupService.ACTION_START_BACKUP
|
||||
serviceIntent.putExtra(BackupService.EXTRA_STATUS_TEXT, "正在备份 ${toBackup.size} 个应用…")
|
||||
try {
|
||||
ContextCompat.startForegroundService(requireContext(), serviceIntent)
|
||||
} catch (_: Exception) {}
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val outputDir = File(config.outputPath.ifEmpty {
|
||||
requireContext().filesDir.absolutePath
|
||||
})
|
||||
WifiManager.backup(outputDir)
|
||||
val result = BackupOperation.backupApps(
|
||||
apps = toBackup,
|
||||
config = config,
|
||||
outputDir = outputDir,
|
||||
onProgress = { progress ->
|
||||
val label = toBackup.find { it.packageName == progress.packageName }?.label
|
||||
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
|
||||
binding.statusText.text =
|
||||
"[${progress.current}/${progress.total}] $name: ${progress.message}"
|
||||
}
|
||||
)
|
||||
try {
|
||||
val outputDir = File(config.outputPath.ifEmpty {
|
||||
requireContext().filesDir.absolutePath
|
||||
})
|
||||
|
||||
// If restic is enabled, snapshot the backup to a restic repository
|
||||
var resticSummary: ResticWrapper.BackupSummary? = null
|
||||
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
|
||||
val binaryPath = ResticBinary.prepare(requireContext())
|
||||
if (binaryPath != null) {
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
|
||||
ResticWrapper.backendDomain = config.resticBackendDomain
|
||||
// ── Restic pre-flight: load snapshot metadata for cumulative merge ──
|
||||
var snapshotApps: Map<String, ResticWrapper.SnapshotAppInfo>? = null
|
||||
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
|
||||
updateStatus("正在检查 restic 历史快照…")
|
||||
|
||||
// For local repos, verify init before attempting backup
|
||||
if (config.resticBackend == "local") {
|
||||
if (!File(config.resticRepo, "config").exists()) {
|
||||
binding.statusText.text = "restic 本地仓库未初始化,请先在设置中初始化"
|
||||
setRunning(false)
|
||||
binding.scanButton.isEnabled = true
|
||||
return@launch
|
||||
if (config.resticBackend == "local" && !File(config.resticRepo, "config").exists()) {
|
||||
updateStatus("restic 本地仓库未初始化,请先在设置中初始化")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val binaryPath = ResticBinary.prepare(requireContext())
|
||||
if (binaryPath != null) {
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.cacheDir = requireContext().cacheDir.absolutePath
|
||||
ResticWrapper.backendDomain = config.resticBackendDomain
|
||||
|
||||
snapshotApps = ResticWrapper.getLatestSnapshotAppDetails(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare
|
||||
)
|
||||
if (snapshotApps != null) {
|
||||
updateStatus("发现历史快照,将合并为累积备份")
|
||||
}
|
||||
}
|
||||
binding.statusText.text = "正在写入 restic 去重仓库…"
|
||||
val resticResult = ResticWrapper.backup(
|
||||
}
|
||||
|
||||
// ── Build merged app list for cumulative snapshot ──
|
||||
val selectedPkgs = toBackup.map { it.packageName.value }.toSet()
|
||||
val allApps: List<AppInfo>
|
||||
val includePkgs: Set<String>
|
||||
|
||||
if (snapshotApps != null) {
|
||||
// Create placeholder AppInfo entries for packages from the snapshot
|
||||
// that are NOT in the current selection. These won't be re-backed-up
|
||||
// but their metadata is preserved via legacyApps.
|
||||
val snapshotOnly = snapshotApps.keys.filter { it !in selectedPkgs }
|
||||
val legacyEntries = snapshotOnly.mapNotNull { pkg ->
|
||||
val snap = snapshotApps[pkg] ?: return@mapNotNull null
|
||||
AppInfo(
|
||||
packageName = PackageName(pkg),
|
||||
label = snap.label,
|
||||
isSystem = snap.isSystem
|
||||
)
|
||||
}
|
||||
allApps = toBackup + legacyEntries
|
||||
includePkgs = selectedPkgs
|
||||
val snapCount = legacyEntries.size
|
||||
if (snapCount > 0) {
|
||||
updateStatus("累积备份: ${allApps.size} 个应用 ($snapCount 个来自历史快照)")
|
||||
}
|
||||
|
||||
// Restore latest snapshot to populate directories for unchanged apps
|
||||
updateStatus("正在恢复历史快照…")
|
||||
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_${selectedUserId}")
|
||||
backupRoot.mkdirs()
|
||||
val snapsResult = ResticWrapper.listSnapshots(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
paths = listOf(result.outputDir),
|
||||
tags = listOf("backup_${System.currentTimeMillis() / 1000}"),
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare
|
||||
)
|
||||
val latestSnap = (snapsResult as? AppResult.Success)?.data?.firstOrNull()
|
||||
if (latestSnap != null) {
|
||||
ResticWrapper.restore(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
snapshotId = latestSnap.shortId,
|
||||
targetPath = backupRoot.absolutePath,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare
|
||||
)
|
||||
}
|
||||
} else {
|
||||
allApps = toBackup
|
||||
includePkgs = emptySet()
|
||||
}
|
||||
|
||||
// ── Execute backup (with cumulative metadata) ──
|
||||
updateStatus("正在备份: ${allApps.size} 个应用…")
|
||||
val result = BackupOperation.backupApps(
|
||||
context = requireContext(),
|
||||
apps = allApps,
|
||||
config = config,
|
||||
outputDir = outputDir,
|
||||
userId = selectedUserId.toString(),
|
||||
noDataBackup = excludeDataFromBackup.toSet(),
|
||||
includePkgs = includePkgs,
|
||||
legacyApps = snapshotApps,
|
||||
onProgress = { progress ->
|
||||
val label = allApps.find { it.packageName.value == progress.packageName }?.label
|
||||
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
|
||||
updateStatus("[${progress.current}/${progress.total}] $name: ${progress.message}")
|
||||
}
|
||||
)
|
||||
|
||||
// Store WiFi config inside Backup_* directory so restic/local restore can find it
|
||||
WifiManager.backup(File(result.outputDir))
|
||||
|
||||
// If restic is enabled, snapshot to repository
|
||||
var resticSummary: ResticWrapper.BackupSummary? = null
|
||||
var resticError: String? = null
|
||||
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
|
||||
val binaryPath = ResticBinary.prepare(requireContext())
|
||||
if (binaryPath != null) {
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.cacheDir = requireContext().cacheDir.absolutePath
|
||||
ResticWrapper.backendDomain = config.resticBackendDomain
|
||||
|
||||
if (config.resticBackend == "local") {
|
||||
if (!File(config.resticRepo, "config").exists()) {
|
||||
updateStatus("restic 本地仓库未初始化,请先在设置中初始化")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
updateStatus("正在写入 restic 去重仓库…")
|
||||
val resticResult = ResticWrapper.backup(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
paths = listOf(result.outputDir),
|
||||
tags = listOf("backup_${System.currentTimeMillis() / 1000}"),
|
||||
hostname = "android-backup-gui",
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
onProgress = { progress ->
|
||||
if (progress.messageType == "status") {
|
||||
updateStatus("去重仓库: %.0f%% (%d/%d 个文件)".format(
|
||||
progress.percentDone * 100,
|
||||
progress.filesDone,
|
||||
progress.totalFiles
|
||||
))
|
||||
}
|
||||
}
|
||||
)
|
||||
when (resticResult) {
|
||||
is AppResult.Success -> resticSummary = resticResult.data
|
||||
is AppResult.Failure -> {
|
||||
resticError = resticResult.error.message
|
||||
updateStatus("restic 快照失败: ${resticResult.error.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus(buildString {
|
||||
appendLine("备份完成!")
|
||||
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
|
||||
appendLine("耗时: ${result.elapsedMs / 1000}秒")
|
||||
appendLine("输出: ${result.outputDir}")
|
||||
appendLine("模式: 累积快照")
|
||||
val summary = resticSummary
|
||||
if (summary != null) {
|
||||
appendLine()
|
||||
appendLine("── Restic 快照 ──")
|
||||
appendLine("ID: ${summary.snapshotId.take(8)}…")
|
||||
appendLine("新增: ${summary.dataAdded / 1024 / 1024} MB")
|
||||
appendLine("文件: ${summary.totalFilesProcessed}")
|
||||
} else {
|
||||
val err = resticError
|
||||
if (err != null) {
|
||||
appendLine()
|
||||
appendLine("── Restic 错误 ──")
|
||||
appendLine(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
updateStatus("备份异常: ${e.message}")
|
||||
} finally {
|
||||
setRunning(false)
|
||||
binding.backupButton.isEnabled = true
|
||||
binding.scanButton.isEnabled = true
|
||||
// Stop foreground service
|
||||
try {
|
||||
val stopIntent = Intent(requireContext(), BackupService::class.java)
|
||||
stopIntent.action = BackupService.ACTION_STOP_BACKUP
|
||||
requireContext().startService(stopIntent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun setRunning(running: Boolean) {
|
||||
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private suspend fun updateStatus(text: String) {
|
||||
withContext(Dispatchers.Main) { binding.statusText.text = text }
|
||||
}
|
||||
|
||||
private fun updateOutputPathDisplay() {
|
||||
val path = config.outputPath.ifEmpty { requireContext().filesDir.absolutePath }
|
||||
binding.outputPathLabel.text = path
|
||||
}
|
||||
|
||||
|
||||
|
||||
private fun showOutputPathEditDialog() {
|
||||
val editText = android.widget.EditText(requireContext()).apply {
|
||||
setText(config.outputPath)
|
||||
hint = requireContext().filesDir.absolutePath
|
||||
}
|
||||
com.google.android.material.dialog.MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("修改输出目录")
|
||||
.setView(editText)
|
||||
.setPositiveButton("确定") { _, _ ->
|
||||
val newPath = editText.text.toString().trim()
|
||||
config = config.copy(outputPath = newPath)
|
||||
BackupConfig.toFile(config, File(requireContext().filesDir, "backup_settings.conf"))
|
||||
updateOutputPathDisplay()
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
// ── Space detection & streaming backup ────────────
|
||||
|
||||
/**
|
||||
* Estimate the total size of data to back up using `du -sb`.
|
||||
* Only counts data directories (not APKs) since that's the bulk.
|
||||
*/
|
||||
private suspend fun estimateBackupSize(apps: List<com.example.androidbackupgui.backup.AppInfo>): Long = withContext(Dispatchers.IO) {
|
||||
var total = 0L
|
||||
for (app in apps) {
|
||||
val pkgEsc = app.packageName.value.shellEscape()
|
||||
val result = RootShell.exec("du -sb /data/data/$pkgEsc 2>/dev/null | cut -f1")
|
||||
val size = result.output.trim().toLongOrNull() ?: 0L
|
||||
total += size
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if [path] has at least [neededBytes] bytes free.
|
||||
* Uses [StatFs] to query the filesystem.
|
||||
*/
|
||||
private fun hasEnoughSpace(path: File, neededBytes: Long): Boolean {
|
||||
try {
|
||||
val stat = StatFs(path.absolutePath)
|
||||
val available = stat.availableBlocksLong * stat.blockSizeLong
|
||||
// Require 1.5x headroom for temp files and metadata
|
||||
return available >= neededBytes * 3 / 2
|
||||
} catch (_: Exception) {
|
||||
// If we can't check, assume enough space (staging mode)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run streaming backup via [StreamingBackup] + [ResticWrapper.backupStdin].
|
||||
* Used when staging space is insufficient.
|
||||
*/
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
private suspend fun runStreamingResticBackup(
|
||||
config: com.example.androidbackupgui.backup.BackupConfig,
|
||||
apps: List<com.example.androidbackupgui.backup.AppInfo>,
|
||||
outputDir: File,
|
||||
cacheDir: String
|
||||
): ResticWrapper.BackupSummary? {
|
||||
updateStatus("空间不足,启动流式备份模式…")
|
||||
|
||||
val cacheDirFile = File(cacheDir, "streaming_tmp")
|
||||
cacheDirFile.mkdirs()
|
||||
|
||||
// Prepare streaming: create FIFO, metadata, collect APK paths
|
||||
val streamingResult = StreamingBackup.prepareStreaming(
|
||||
cacheDirFile, apps, null
|
||||
)
|
||||
|
||||
// Start restic with stdin from FIFO, in parallel with data producer
|
||||
var summary: ResticWrapper.BackupSummary? = null
|
||||
var backupError: String? = null
|
||||
|
||||
coroutineScope {
|
||||
// Launch restic backup (consumer)
|
||||
val resticJob = async {
|
||||
val result = ResticWrapper.backupStdin(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
stdinFile = streamingResult.dataFifo,
|
||||
extraPaths = streamingResult.apkPaths + streamingResult.metaDir.absolutePath,
|
||||
tags = listOf("streaming_${System.currentTimeMillis() / 1000}"),
|
||||
hostname = "android-backup-gui",
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
onSyncProgress = { progress: RemoteTransport.TransferProgress ->
|
||||
withContext(Dispatchers.Main) {
|
||||
when (progress.phase) {
|
||||
"list", "download", "upload", "delete_stale" ->
|
||||
binding.statusText.text = "同步中: ${progress.current}/${progress.total} 个文件"
|
||||
}
|
||||
}
|
||||
},
|
||||
onByteSyncProgress = { progress ->
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
|
||||
binding.progressBar.progress = progress.bytesTransferred.toInt()
|
||||
binding.statusText.text = "同步中: ${progress.currentFile}\n" +
|
||||
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}"
|
||||
}
|
||||
},
|
||||
onProgress = { progress ->
|
||||
if (progress.messageType == "status") {
|
||||
binding.statusText.text = "去重仓库: %.0f%% (%d/%d 个文件)".format(
|
||||
updateStatus("流式去重仓库: %.0f%% (%d/%d 个文件)".format(
|
||||
progress.percentDone * 100,
|
||||
progress.filesDone,
|
||||
progress.totalFiles
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
)
|
||||
resticResult.fold(
|
||||
onSuccess = { resticSummary = it },
|
||||
onFailure = { e ->
|
||||
binding.statusText.text = "restic 快照失败: ${e.message}"
|
||||
}
|
||||
when (result) {
|
||||
is AppResult.Success -> summary = result.data
|
||||
is AppResult.Failure -> backupError = result.error.message
|
||||
}
|
||||
}
|
||||
|
||||
// Launch data producer (writes tar to FIFO)
|
||||
val producerJob = async {
|
||||
StreamingBackup.launchDataProducer(
|
||||
apps = apps,
|
||||
noDataBackup = excludeDataFromBackup.toSet(),
|
||||
userId = selectedUserId.toString(),
|
||||
fifoPath = streamingResult.dataFifo.absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for both to complete
|
||||
producerJob.await()
|
||||
resticJob.await()
|
||||
}
|
||||
|
||||
binding.statusText.text = buildString {
|
||||
appendLine("备份完成!")
|
||||
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
|
||||
appendLine("耗时: ${result.elapsedMs / 1000}秒")
|
||||
appendLine("输出: ${result.outputDir}")
|
||||
if (resticSummary != null) {
|
||||
appendLine()
|
||||
appendLine("── Restic 快照 ──")
|
||||
appendLine("ID: ${resticSummary!!.snapshotId.take(8)}…")
|
||||
appendLine("新增: ${resticSummary!!.dataAdded / 1024 / 1024} MB")
|
||||
appendLine("文件: ${resticSummary!!.totalFilesProcessed}")
|
||||
}
|
||||
// Cleanup FIFO
|
||||
try { streamingResult.dataFifo.delete() } catch (_: Exception) {}
|
||||
try { streamingResult.metaDir.deleteRecursively() } catch (_: Exception) {}
|
||||
|
||||
if (backupError != null) {
|
||||
updateStatus("流式备份失败: $backupError")
|
||||
}
|
||||
setRunning(false)
|
||||
binding.scanButton.isEnabled = true
|
||||
return summary
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatSize(bytes: Long): String {
|
||||
if (bytes < 1024) return "$bytes B"
|
||||
val units = arrayOf("KB", "MB", "GB", "TB")
|
||||
val exp = (63 - bytes.countLeadingZeroBits()) / 10
|
||||
val value = bytes.toDouble() / (1L shl (exp * 10))
|
||||
return "%.1f %s".format(Locale.US, value, units[exp - 1].coerceAtMost(units.last()))
|
||||
}
|
||||
|
||||
private fun setRunning(running: Boolean) {
|
||||
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||
ResticWrapper.cleanup()
|
||||
}
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,22 @@ 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 kotlinx.coroutines.withTimeoutOrNull
|
||||
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 +56,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 +94,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 +140,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 +156,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 +188,39 @@ 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 = withTimeoutOrNull(60_000L) {
|
||||
ResticWrapper.init(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
}
|
||||
if (result == null) {
|
||||
_operationEvents.emit(OperationEvent.InitFailed)
|
||||
Log.w(TAG, "initResticRepo timed out after 1 minute")
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "仓库初始化成功: ${form.repo}", initButtonEnabled = true
|
||||
message = "初始化超时(1分钟),请检查网络/SMB 服务器是否正常"
|
||||
))}
|
||||
refreshResticStatus(form)
|
||||
},
|
||||
onFailure = { e ->
|
||||
Log.e(TAG, "initResticRepo failed", e)
|
||||
} else if (result.isSuccess) {
|
||||
_operationEvents.emit(OperationEvent.InitCompleted)
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "初始化失败: ${e.message}", initButtonEnabled = true
|
||||
message = "仓库初始化成功: ${form.repo}"
|
||||
))}
|
||||
refreshResticStatus(form)
|
||||
} else {
|
||||
_operationEvents.emit(OperationEvent.InitFailed)
|
||||
Log.e(TAG, "initResticRepo failed: ${result.exceptionOrNull()?.message}")
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "初始化失败: ${result.exceptionOrNull()?.message}"
|
||||
))}
|
||||
refreshResticStatus(form)
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
initGuard.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,8 +249,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 +265,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 +311,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,23 +4,27 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.example.androidbackupgui.backup.AppInfo
|
||||
import com.example.androidbackupgui.backup.PackageName
|
||||
import com.example.androidbackupgui.backup.AppScanner
|
||||
import com.example.androidbackupgui.backup.BackupConfig
|
||||
import com.example.androidbackupgui.backup.RestoreOperation
|
||||
import com.example.androidbackupgui.backup.ResticBinary
|
||||
import com.example.androidbackupgui.backup.ResticWrapper
|
||||
import com.example.androidbackupgui.backup.WifiManager
|
||||
import com.example.androidbackupgui.backup.RemoteTransport
|
||||
import com.example.androidbackupgui.databinding.FragmentRestoreBinding
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class RestoreFragment : Fragment() {
|
||||
|
||||
@@ -32,6 +36,9 @@ class RestoreFragment : Fragment() {
|
||||
private var selectedPackages = mutableSetOf<String>()
|
||||
private var resticConfig: BackupConfig? = null
|
||||
private var selectedSnapshot: ResticWrapper.ResticSnapshot? = null
|
||||
private var resticConfigFingerprint: String? = null
|
||||
private var selectedUserId: Int = 0
|
||||
private var userList: List<Pair<Int, String>> = listOf(0 to "Owner")
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
@@ -42,6 +49,7 @@ class RestoreFragment : Fragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.appList.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
||||
// Load restic config
|
||||
@@ -54,7 +62,7 @@ class RestoreFragment : Fragment() {
|
||||
val binaryPath = ResticBinary.prepare(requireContext())
|
||||
if (binaryPath != null) {
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
|
||||
ResticWrapper.cacheDir = requireContext().cacheDir.absolutePath
|
||||
ResticWrapper.backendDomain = config.resticBackendDomain
|
||||
binding.selectResticButton.visibility = View.VISIBLE
|
||||
}
|
||||
@@ -63,6 +71,65 @@ class RestoreFragment : Fragment() {
|
||||
binding.selectDirButton.setOnClickListener { selectBackupDir() }
|
||||
binding.selectResticButton.setOnClickListener { selectResticSnapshot() }
|
||||
binding.restoreButton.setOnClickListener { startRestore() }
|
||||
|
||||
// Load user profiles
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
private fun loadUsers() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
userList = AppScanner.enumerateUsers()
|
||||
val names = userList.map { (id, name) -> "$name (ID: $id)" }
|
||||
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names)
|
||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
binding.userSelector.adapter = adapter
|
||||
binding.userSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
selectedUserId = userList.getOrNull(position)?.first ?: 0
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
binding.statusText.text = "加载用户失败: ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Re-read config so changes from ConfigFragment take effect immediately
|
||||
val configFile = File(requireContext().filesDir, "backup_settings.conf")
|
||||
val config = BackupConfig.fromFile(configFile)
|
||||
|
||||
// Detect restic config change — clear stale state if repo/backend changed
|
||||
val newFingerprint = "${config.resticRepo}|${config.resticBackend}|${config.resticBackendUrl}"
|
||||
if (resticConfigFingerprint != null && resticConfigFingerprint != newFingerprint) {
|
||||
selectedSnapshot = null
|
||||
packages = emptyList()
|
||||
selectedPackages.clear()
|
||||
binding.backupDirText.text = ""
|
||||
binding.restoreButton.isEnabled = false
|
||||
binding.selectResticButton.visibility = View.GONE
|
||||
}
|
||||
resticConfigFingerprint = newFingerprint
|
||||
|
||||
resticConfig = if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) config else null
|
||||
// Skip redundant preparation if binary and backend config are already set
|
||||
if (resticConfig != null &&
|
||||
ResticWrapper.binaryPath.isNotEmpty() &&
|
||||
ResticWrapper.binaryPath != "restic"
|
||||
) {
|
||||
binding.selectResticButton.visibility = View.VISIBLE
|
||||
} else {
|
||||
val binaryPath = ResticBinary.prepare(requireContext())
|
||||
if (binaryPath != null && resticConfig != null) {
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.cacheDir = requireContext().cacheDir.absolutePath
|
||||
ResticWrapper.backendDomain = config.resticBackendDomain
|
||||
binding.selectResticButton.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectBackupDir() {
|
||||
@@ -101,74 +168,102 @@ class RestoreFragment : Fragment() {
|
||||
binding.statusText.text = "共 ${packages.size} 个备份应用"
|
||||
binding.restoreButton.isEnabled = packages.isNotEmpty()
|
||||
|
||||
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = it) })
|
||||
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = PackageName(it)) })
|
||||
setupAppList()
|
||||
}
|
||||
|
||||
private fun selectResticSnapshot() {
|
||||
val config = resticConfig ?: return
|
||||
setRunning(true)
|
||||
binding.statusText.text = "正在读取 restic 快照列表…"
|
||||
binding.statusText.text = "正在同步远程仓库到本地…"
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val snapshotsResult = ResticWrapper.listSnapshots(
|
||||
config.resticRepo, config.resticPassword,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare
|
||||
)
|
||||
if (snapshotsResult.isFailure) {
|
||||
binding.statusText.text = "读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}"
|
||||
try {
|
||||
val snapshotsResult = ResticWrapper.listSnapshots(
|
||||
config.resticRepo, config.resticPassword,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
)
|
||||
if (snapshotsResult.isFailure) {
|
||||
updateStatus("读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}")
|
||||
setRunning(false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val snapshots = snapshotsResult.getOrThrow()
|
||||
if (snapshots.isEmpty()) {
|
||||
updateStatus("没有可用的 restic 快照")
|
||||
setRunning(false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 多快照时让用户选择,单个快照自动选
|
||||
val chosenSnapshot = if (snapshots.size == 1) {
|
||||
snapshots.first()
|
||||
} else {
|
||||
pickSnapshot(snapshots) ?: run {
|
||||
updateStatus("已取消选择")
|
||||
setRunning(false)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to restic source
|
||||
backupDir = null
|
||||
selectedSnapshot = chosenSnapshot
|
||||
val backupPath = selectedSnapshot!!.paths.firstOrNull() ?: run {
|
||||
updateStatus("快照中找不到备份路径")
|
||||
setRunning(false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Read app list from the snapshot
|
||||
val appListContent = readResticFile(config, selectedSnapshot!!.id, "$backupPath/appList.txt")
|
||||
packages = if (appListContent != null) {
|
||||
appListContent.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
if (packages.isEmpty()) {
|
||||
updateStatus("无法从快照读取应用列表")
|
||||
setRunning(false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
binding.backupDirText.text = "restic: ${selectedSnapshot!!.time.take(19)} (${snapshots.size} 个快照可用)"
|
||||
selectedPackages.clear()
|
||||
|
||||
selectedPackages.addAll(packages)
|
||||
|
||||
// Resolve app labels for display
|
||||
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = PackageName(it)) })
|
||||
|
||||
updateStatus("restic 快照共 ${packages.size} 个应用,点击恢复开始")
|
||||
binding.restoreButton.isEnabled = true
|
||||
setRunning(false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val snapshots = snapshotsResult.getOrThrow()
|
||||
if (snapshots.isEmpty()) {
|
||||
binding.statusText.text = "没有可用的 restic 快照"
|
||||
setupAppList()
|
||||
} catch (e: Exception) {
|
||||
binding.statusText.text = "选择快照失败: ${e.message}"
|
||||
setRunning(false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Switch to restic source
|
||||
backupDir = null
|
||||
selectedSnapshot = snapshots.first()
|
||||
val backupPath = selectedSnapshot!!.paths.firstOrNull() ?: run {
|
||||
binding.statusText.text = "快照中找不到备份路径"
|
||||
setRunning(false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Read app list from the snapshot
|
||||
val appListContent = readResticFile(config, selectedSnapshot!!.id, "$backupPath/appList.txt")
|
||||
packages = if (appListContent != null) {
|
||||
appListContent.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
if (packages.isEmpty()) {
|
||||
binding.statusText.text = "无法从快照读取应用列表"
|
||||
setRunning(false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
binding.backupDirText.text = "restic: ${selectedSnapshot!!.time.take(19)} (${snapshots.size} 个快照可用)"
|
||||
selectedPackages.clear()
|
||||
selectedPackages.addAll(packages)
|
||||
|
||||
// Resolve app labels for display
|
||||
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = it) })
|
||||
|
||||
binding.statusText.text = "restic 快照共 ${packages.size} 个应用,点击恢复开始"
|
||||
binding.restoreButton.isEnabled = true
|
||||
setRunning(false)
|
||||
setupAppList()
|
||||
}
|
||||
}
|
||||
|
||||
/** 多快照时弹出选择对话框。返回用户选择的快照,取消时返回 null。 */
|
||||
private suspend fun pickSnapshot(snapshots: List<ResticWrapper.ResticSnapshot>): ResticWrapper.ResticSnapshot? =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val names = snapshots.map { "${it.time.take(19)} (${it.id.take(8)})" }
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setTitle("选择快照")
|
||||
.setItems(names.toTypedArray()) { _, i -> cont.resume(snapshots[i]) }
|
||||
.setOnCancelListener { cont.resume(null) }
|
||||
.show()
|
||||
}
|
||||
|
||||
/** Read a single file from a restic snapshot using `restic dump`. */
|
||||
private suspend fun readResticFile(
|
||||
config: BackupConfig,
|
||||
@@ -188,10 +283,13 @@ class RestoreFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun setupAppList() {
|
||||
binding.appList.adapter = PackageListAdapter(appInfos, selectedPackages) { pkg, checked ->
|
||||
if (checked) selectedPackages.add(pkg) else selectedPackages.remove(pkg)
|
||||
binding.statusText.text = "已选择 ${selectedPackages.size}/${packages.size} 个应用"
|
||||
}
|
||||
binding.appList.adapter = PackageListAdapter(
|
||||
appInfos, selectedPackages,
|
||||
onToggle = { pkg, checked ->
|
||||
if (checked) selectedPackages.add(pkg) else selectedPackages.remove(pkg)
|
||||
binding.statusText.text = "已选择 ${selectedPackages.size}/${packages.size} 个应用"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun startRestore() {
|
||||
@@ -203,116 +301,105 @@ class RestoreFragment : Fragment() {
|
||||
binding.selectDirButton.isEnabled = false
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = if (selectedSnapshot != null && resticConfig != null) {
|
||||
// Restic restore
|
||||
val snapshot = selectedSnapshot!!
|
||||
val config = resticConfig!!
|
||||
val backupPath = snapshot.paths.firstOrNull() ?: return@launch
|
||||
try {
|
||||
val result = if (selectedSnapshot != null && resticConfig != null) {
|
||||
// Restic restore
|
||||
val snapshot = selectedSnapshot ?: return@launch
|
||||
val config = resticConfig ?: return@launch
|
||||
val backupPath = snapshot.paths.firstOrNull() ?: return@launch
|
||||
val staging = File(requireContext().cacheDir, "restic_restore_${snapshot.shortId}")
|
||||
staging.mkdirs()
|
||||
try {
|
||||
binding.progressBar.isIndeterminate = true
|
||||
|
||||
val staging = File(requireContext().cacheDir, "restic_restore_${snapshot.shortId}")
|
||||
staging.mkdirs()
|
||||
binding.statusText.text = "正在从 restic 快照恢复到暂存目录…"
|
||||
val restoreResult = ResticWrapper.restore(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
snapshotId = snapshot.id,
|
||||
targetPath = staging.absolutePath,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
onProgress = { msg -> withContext(Dispatchers.Main) { binding.statusText.text = msg } }
|
||||
)
|
||||
|
||||
binding.statusText.text = "正在从 restic 快照恢复到暂存目录…"
|
||||
val restoreResult = ResticWrapper.restore(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
snapshotId = snapshot.id,
|
||||
targetPath = staging.absolutePath,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
onSyncProgress = { progress: RemoteTransport.TransferProgress ->
|
||||
withContext(Dispatchers.Main) {
|
||||
when (progress.phase) {
|
||||
"list", "download", "upload", "delete_stale" ->
|
||||
binding.statusText.text = "同步中: ${progress.current}/${progress.total} 个文件"
|
||||
if (restoreResult.isFailure) {
|
||||
updateStatus("restic 恢复失败: ${restoreResult.exceptionOrNull()?.message}")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// The restored backup directory: <staging>/<original_absolute_path>
|
||||
val restoredBackupDir = File(staging, backupPath.removePrefix("/"))
|
||||
updateStatus("正在从恢复的备份安装应用…")
|
||||
|
||||
val r = RestoreOperation.restoreApps(
|
||||
context = requireContext(),
|
||||
backupDir = restoredBackupDir,
|
||||
userId = selectedUserId.toString(),
|
||||
filterPkgs = selectedPackages,
|
||||
onProgress = { progress ->
|
||||
val label = appInfos.find { it.packageName.value == progress.packageName }?.label
|
||||
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
|
||||
binding.statusText.text =
|
||||
"[${progress.current}/${progress.total}] $name: ${progress.message}"
|
||||
}
|
||||
)
|
||||
// Also restore WiFi if backup exists
|
||||
WifiManager.restore(restoredBackupDir)
|
||||
r
|
||||
} finally {
|
||||
try { staging.deleteRecursively() } catch (_: Exception) {}
|
||||
}
|
||||
} else {
|
||||
// Local restore
|
||||
val dir = backupDir ?: return@launch
|
||||
val r = RestoreOperation.restoreApps(
|
||||
context = requireContext(),
|
||||
backupDir = dir,
|
||||
userId = selectedUserId.toString(),
|
||||
filterPkgs = selectedPackages,
|
||||
onProgress = { progress ->
|
||||
val label = appInfos.find { it.packageName.value == progress.packageName }?.label
|
||||
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
|
||||
binding.statusText.text =
|
||||
"[${progress.current}/${progress.total}] $name: ${progress.message}"
|
||||
}
|
||||
},
|
||||
onByteSyncProgress = { progress ->
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
|
||||
binding.progressBar.progress = progress.bytesTransferred.toInt()
|
||||
binding.statusText.text = "同步中: ${progress.currentFile}\n" +
|
||||
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}"
|
||||
}
|
||||
},
|
||||
onProgress = { msg -> binding.statusText.text = msg }
|
||||
)
|
||||
|
||||
if (restoreResult.isFailure) {
|
||||
binding.statusText.text = "restic 恢复失败: ${restoreResult.exceptionOrNull()?.message}"
|
||||
setRunning(false)
|
||||
binding.selectDirButton.isEnabled = true
|
||||
return@launch
|
||||
)
|
||||
// Also restore WiFi if backup exists locally
|
||||
WifiManager.restore(dir)
|
||||
r
|
||||
}
|
||||
|
||||
// The restored backup directory: <staging>/<original_absolute_path>
|
||||
val restoredBackupDir = File(staging, backupPath.removePrefix("/"))
|
||||
binding.statusText.text = "正在从恢复的备份安装应用…"
|
||||
|
||||
val r = RestoreOperation.restoreApps(
|
||||
backupDir = restoredBackupDir,
|
||||
filterPkgs = selectedPackages,
|
||||
onProgress = { progress ->
|
||||
val label = appInfos.find { it.packageName == progress.packageName }?.label
|
||||
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
|
||||
binding.statusText.text =
|
||||
"[${progress.current}/${progress.total}] $name: ${progress.message}"
|
||||
}
|
||||
)
|
||||
// Also restore WiFi if backup exists
|
||||
WifiManager.restore(restoredBackupDir)
|
||||
// Cleanup staging
|
||||
try { staging.deleteRecursively() } catch (_: Exception) {}
|
||||
r
|
||||
} else {
|
||||
// Local restore
|
||||
val dir = backupDir ?: return@launch
|
||||
val r = RestoreOperation.restoreApps(
|
||||
backupDir = dir,
|
||||
filterPkgs = selectedPackages,
|
||||
onProgress = { progress ->
|
||||
val label = appInfos.find { it.packageName == progress.packageName }?.label
|
||||
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
|
||||
binding.statusText.text =
|
||||
"[${progress.current}/${progress.total}] $name: ${progress.message}"
|
||||
}
|
||||
)
|
||||
// Also restore WiFi if backup exists locally
|
||||
WifiManager.restore(dir)
|
||||
r
|
||||
binding.statusText.text = buildString {
|
||||
appendLine("恢复完成!")
|
||||
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
|
||||
appendLine("耗时: ${result.elapsedMs / 1000}秒")
|
||||
appendLine("如有 SSAID,请立即重启设备后再开启应用")
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
binding.statusText.text = "恢复异常: ${e.message}"
|
||||
} finally {
|
||||
setRunning(false)
|
||||
binding.selectDirButton.isEnabled = true
|
||||
}
|
||||
|
||||
binding.statusText.text = buildString {
|
||||
appendLine("恢复完成!")
|
||||
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
|
||||
appendLine("耗时: ${result.elapsedMs / 1000}秒")
|
||||
appendLine("如有 SSAID,请立即重启设备后再开启应用")
|
||||
}
|
||||
setRunning(false)
|
||||
binding.selectDirButton.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun setRunning(running: Boolean) {
|
||||
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun formatSize(bytes: Long): String {
|
||||
if (bytes < 1024) return "$bytes B"
|
||||
val units = arrayOf("KB", "MB", "GB", "TB")
|
||||
val exp = (63 - bytes.countLeadingZeroBits()) / 10
|
||||
val value = bytes.toDouble() / (1L shl (exp * 10))
|
||||
return "%.1f %s".format(Locale.US, value, units[exp - 1].coerceAtMost(units.last()))
|
||||
private suspend fun updateStatus(text: String) {
|
||||
binding.statusText.text = text
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||
ResticWrapper.cleanup()
|
||||
}
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
BIN
app/src/main/jniLibs/arm64-v8a/libtar_bin.so
Normal file
BIN
app/src/main/jniLibs/arm64-v8a/libtar_bin.so
Normal file
Binary file not shown.
BIN
app/src/main/jniLibs/arm64-v8a/libzstd_bin.so
Normal file
BIN
app/src/main/jniLibs/arm64-v8a/libzstd_bin.so
Normal file
Binary file not shown.
@@ -4,7 +4,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp"
|
||||
android:padding="@dimen/fragment_horizontal_padding"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<LinearLayout
|
||||
@@ -29,6 +29,126 @@
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="用户: "
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/userSelector"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="输出目录: "
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/outputPathLabel"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="middle"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/outputPathEdit"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="修改" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/sortAZButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="2dp"
|
||||
android:text="A-Z"
|
||||
android:textSize="11sp"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/sortSizeButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="2dp"
|
||||
android:layout_marginEnd="2dp"
|
||||
android:text="大小"
|
||||
android:textSize="11sp"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/selectAllButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="2dp"
|
||||
android:layout_marginEnd="2dp"
|
||||
android:text="全选"
|
||||
android:textSize="11sp"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/deselectAllButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="2dp"
|
||||
android:text="取消全选"
|
||||
android:textSize="11sp"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/showSystemSwitch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="显示系统应用"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:checked="false" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="match_parent"
|
||||
@@ -44,6 +164,8 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:maxLines="3"
|
||||
android:ellipsize="end"
|
||||
android:text="点击扫描以载入应用列表"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp"
|
||||
android:padding="@dimen/fragment_horizontal_padding"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<LinearLayout
|
||||
@@ -38,6 +38,27 @@
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="用户: "
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/userSelector"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/backupDirText"
|
||||
android:layout_width="match_parent"
|
||||
@@ -64,6 +85,8 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:maxLines="3"
|
||||
android:ellipsize="end"
|
||||
android:text="请先选择备份文件夹"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
57
app/src/main/res/values-night-v27/themes.xml
Normal file
57
app/src/main/res/values-night-v27/themes.xml
Normal 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>
|
||||
17
app/src/main/res/values-sw600dp/dimens.xml
Normal file
17
app/src/main/res/values-sw600dp/dimens.xml
Normal 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>
|
||||
57
app/src/main/res/values-v27/themes.xml
Normal file
57
app/src/main/res/values-v27/themes.xml
Normal 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>
|
||||
17
app/src/main/res/values/dimens.xml
Normal file
17
app/src/main/res/values/dimens.xml
Normal 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>
|
||||
@@ -2,4 +2,5 @@
|
||||
<resources>
|
||||
<item name="checkbox" type="id" />
|
||||
<item name="appName" type="id" />
|
||||
<item name="excludeToggle" type="id" />
|
||||
</resources>
|
||||
|
||||
@@ -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"
|
||||
|
||||
701
docs/superpowers/plans/2026-06-02-android-backup-optimization.md
Normal file
701
docs/superpowers/plans/2026-06-02-android-backup-optimization.md
Normal 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 已 shellEscape,wifiSource 从预定义列表来(安全)
|
||||
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 中修改后后续步骤保持一致引用。
|
||||
@@ -1,12 +0,0 @@
|
||||
# 用户偏好
|
||||
- 交流语言:所有回复必须使用中文。这是核心要求,新对话需自动加载。
|
||||
- 项目:Android Backup GUI(Kotlin 应用)
|
||||
- 工作目录:~/github_projects/android-backup-gui
|
||||
|
||||
# 项目背景
|
||||
Android Backup GUI(Kotlin app with native root execution, SMB/WebDAV remote storage, WiFi backup)和 CodeGraph global setup(MCP server for code intelligence, auto-init prompt in CLAUDE.md)。
|
||||
|
||||
# 技术要点
|
||||
- root shell persistence
|
||||
- SMB troubleshooting (ECONNREFUSED)
|
||||
- 构建命令: ./gradlew assembleDebug
|
||||
Reference in New Issue
Block a user