refactor: Result → AppResult, DomainTypes, cleanup, and other improvements

- Replace kotlin.Result with AppResult across all transports and operations
- Introduce DomainTypes (PackageName, AppInfo) for type safety
- Add AppError sealed hierarchy for structured error handling
- Add SELinuxUtil for SELinux context restoration
- Add values-sw600dp and dimens.xml for tablet layout support
- Sync progress UI refactoring in BackupFragment/RestoreFragment
- BinaryResolver per-binary cache fix
This commit is contained in:
sakuradairong
2026-06-04 21:21:17 +08:00
parent 40f03e5bad
commit f5dd61a83b
35 changed files with 1845 additions and 481 deletions

View File

@@ -1,6 +1,23 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization' apply plugin: 'kotlinx-serialization'
apply plugin: 'org.jetbrains.kotlinx.kover'
kover {
reports {
filters {
excludes {
classes(
// Generated/auto classes
"*.databinding.*",
"*.BuildConfig",
"*.R",
"*.R\$*"
)
}
}
}
}
android { android {
namespace "com.example.androidbackupgui" namespace "com.example.androidbackupgui"
@@ -38,6 +55,12 @@ android {
} }
} }
} }
testOptions {
unitTests.all {
useJUnitPlatform()
}
}
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
@@ -72,4 +95,9 @@ dependencies {
// root shell via libsu (Magisk/KernelSU/APatch) // root shell via libsu (Magisk/KernelSU/APatch)
implementation 'com.github.topjohnwu:libsu:6.0.0' implementation 'com.github.topjohnwu:libsu:6.0.0'
testImplementation "io.kotest:kotest-runner-junit5:5.9.1"
testImplementation "io.kotest:kotest-assertions-core:5.9.1"
testImplementation "io.kotest:kotest-property:5.9.1"
testImplementation "io.mockk:mockk:1.13.12"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
} }

View File

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

View File

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

View File

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

View File

@@ -3,15 +3,15 @@ package com.example.androidbackupgui.backup
import com.example.androidbackupgui.root.RootShell import com.example.androidbackupgui.root.RootShell
import android.util.Log import android.util.Log
import com.example.androidbackupgui.root.shellEscape import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import org.json.JSONObject import org.json.JSONObject
import kotlin.coroutines.coroutineContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.coroutines.coroutineScope 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.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
@@ -68,7 +68,7 @@ object BackupOperation {
// Write app list // Write app list
val appListFile = File(backupRoot, "appList.txt") 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
val metaFile = File(backupRoot, "app_details.json") val metaFile = File(backupRoot, "app_details.json")
@@ -80,17 +80,17 @@ object BackupOperation {
val skippedAtomic = AtomicInteger(0) val skippedAtomic = AtomicInteger(0)
coroutineScope { coroutineScope {
apps.forEachIndexed { index, app -> apps.mapIndexed { index, app ->
launch { async {
if (!coroutineContext.isActive) return@launch
semaphore.withPermit { semaphore.withPermit {
val appDir = File(backupRoot, app.packageName) ensureActive()
val appDir = File(backupRoot, app.packageName.value)
appDir.mkdirs() appDir.mkdirs()
emit(BackupProgress(index + 1, apps.size, app.packageName, "apk", "正在备份 APK…")) emit(BackupProgress(index + 1, apps.size, app.packageName.value, "apk", "正在备份 APK…"))
// 1. Backup APK // 1. Backup APK
val paths = AppScanner.getApkPaths(app.packageName) val paths = AppScanner.getApkPaths(app.packageName.value)
val apkOk = if (paths.isNotEmpty()) { val apkOk = if (paths.isNotEmpty()) {
paths.withIndex().all { (i, apkPath) -> paths.withIndex().all { (i, apkPath) ->
val destName = if (paths.size > 1) "${app.packageName}_split_$i.apk" else "${app.packageName}.apk" val destName = if (paths.size > 1) "${app.packageName}_split_$i.apk" else "${app.packageName}.apk"
@@ -100,57 +100,57 @@ object BackupOperation {
if (!apkOk) { if (!apkOk) {
failAtomic.incrementAndGet() failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "APK 备份失败")) emit(BackupProgress(index + 1, apps.size, app.packageName.value, "done", "APK 备份失败"))
return@withPermit return@withPermit
} }
// 1.5 Keystore check — warn if app has keystore entries (keys can be lost) // 1.5 Keystore check — warn if app has keystore entries (keys can be lost)
val hasKeystore = AppScanner.hasKeystore(app.packageName) val hasKeystore = AppScanner.hasKeystore(app.packageName.value)
if (hasKeystore) { if (hasKeystore) {
emit(BackupProgress(index + 1, apps.size, app.packageName, "data", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失")) emit(BackupProgress(index + 1, apps.size, app.packageName.value, "data", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失"))
} }
// 2. Backup user data (if configured) // 2. Backup user data (if configured)
if (config.backupMode == 1 && config.backupUserData == 1) { if (config.backupMode == 1 && config.backupUserData == 1) {
emit(BackupProgress(index + 1, apps.size, app.packageName, "data", "正在备份数据…")) emit(BackupProgress(index + 1, apps.size, app.packageName.value, "data", "正在备份数据…"))
if (!backupUserData(context, app.packageName, appDir, userId, config.compressionMethod)) { if (!backupUserData(context, app.packageName.value, appDir, userId, config.compressionMethod)) {
failAtomic.incrementAndGet() failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "数据备份失败")) emit(BackupProgress(index + 1, apps.size, app.packageName.value, "done", "数据备份失败"))
return@withPermit return@withPermit
} }
} }
// 3. Backup OBB (if configured and exists) // 3. Backup OBB (if configured and exists)
if (config.backupMode == 1 && config.backupObbData == 1) { if (config.backupMode == 1 && config.backupObbData == 1) {
val hasObb = AppScanner.hasObbData(app.packageName) val hasObb = AppScanner.hasObbData(app.packageName.value)
if (hasObb) { if (hasObb) {
emit(BackupProgress(index + 1, apps.size, app.packageName, "obb", "正在备份 OBB…")) emit(BackupProgress(index + 1, apps.size, app.packageName.value, "obb", "正在备份 OBB…"))
if (!backupObb(app.packageName, appDir, config.compressionMethod)) { if (!backupObb(app.packageName.value, appDir, config.compressionMethod)) {
failAtomic.incrementAndGet() failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "OBB 备份失败")) emit(BackupProgress(index + 1, apps.size, app.packageName.value, "done", "OBB 备份失败"))
return@withPermit return@withPermit
} }
} }
} }
// 4. Backup SSAID // 4. Backup SSAID
emit(BackupProgress(index + 1, apps.size, app.packageName, "ssaid", "正在备份 SSAID…")) emit(BackupProgress(index + 1, apps.size, app.packageName.value, "ssaid", "正在备份 SSAID…"))
backupSsaid(app.packageName, appDir, userId) backupSsaid(app.packageName.value, appDir, userId)
// 4.5 Backup app icon // 4.5 Backup app icon
val iconPath = AppScanner.extractIcon(app.packageName, appDir, app.userId) val iconPath = AppScanner.extractIcon(app.packageName.value, appDir, app.userId.value)
if (iconPath != null) { if (iconPath != null) {
Log.d(TAG, "backupApps: saved icon for ${app.packageName} -> $iconPath") Log.d(TAG, "backupApps: saved icon for ${app.packageName} -> $iconPath")
} }
// 5. Backup runtime permissions // 5. Backup runtime permissions
backupPermissions(app.packageName, appDir) backupPermissions(app.packageName.value, appDir)
successAtomic.incrementAndGet() successAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "完成")) emit(BackupProgress(index + 1, apps.size, app.packageName.value, "done", "完成"))
}
} }
} }
}.awaitAll()
} }
val elapsed = System.currentTimeMillis() - startTime val elapsed = System.currentTimeMillis() - startTime
@@ -209,7 +209,7 @@ object BackupOperation {
var archiveCreated = false var archiveCreated = false
var result: RootShell.ShellResult? = null var result: RootShell.ShellResult? = null
val dirs = dataPaths.filter { RootShell.exec("test -d $it").isSuccess }.toMutableList() val dirs = dataPaths.filter { RootShell.exec("test -d '${it.shellEscape()}'").isSuccess }.toMutableList()
if (dirs.isNotEmpty()) { if (dirs.isNotEmpty()) {
Log.d(TAG, "backupUserData: $packageName test -d found dirs=$dirs") Log.d(TAG, "backupUserData: $packageName test -d found dirs=$dirs")
result = runTar(dirs, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes) result = runTar(dirs, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
@@ -227,9 +227,9 @@ object BackupOperation {
Log.w(TAG, "backupUserData: $packageName step3 trying /proc/1/root") Log.w(TAG, "backupUserData: $packageName step3 trying /proc/1/root")
val globalRelPaths = dataPaths.map { it.removePrefix("/") } val globalRelPaths = dataPaths.map { it.removePrefix("/") }
val globalCmd = if (isZstd) { val globalCmd = if (isZstd) {
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(" ")} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'" "cd /proc/1/root && set -o pipefail; $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
} else { } else {
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ")} 2>/dev/null" "cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null"
} }
result = RootShell.exec(globalCmd) result = RootShell.exec(globalCmd)
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0) archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
@@ -275,12 +275,12 @@ object BackupOperation {
excludes: List<String> = emptyList() excludes: List<String> = emptyList()
): RootShell.ShellResult { ): RootShell.ShellResult {
val excludeArgs = if (excludes.isNotEmpty()) { val excludeArgs = if (excludes.isNotEmpty()) {
excludes.joinToString(" ") { "--exclude='$it'" } excludes.joinToString(" ") { "--exclude='${it.shellEscape()}'" }
} else "" } else ""
return if (isZstd) { return if (isZstd) {
RootShell.exec("$tarCmd -cf - $excludeArgs ${dirs.joinToString(" ")} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'") RootShell.exec("set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'")
} else { } else {
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ")} 2>/dev/null") RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null")
} }
} }
private suspend fun backupObb(packageName: String, appDir: File, compression: String): Boolean { private suspend fun backupObb(packageName: String, appDir: File, compression: String): Boolean {
@@ -290,7 +290,7 @@ object BackupOperation {
// Exclude cache and backup temp files from OBB archive // Exclude cache and backup temp files from OBB archive
val obbExcludes = "--exclude='cache' --exclude='Backup_*'" val obbExcludes = "--exclude='cache' --exclude='Backup_*'"
val result = when (compression) { val result = when (compression) {
"zstd" -> RootShell.exec("tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'") "zstd" -> RootShell.exec("set -o pipefail; tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'")
else -> RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null") else -> RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
} }
if (!result.isSuccess) { if (!result.isSuccess) {
@@ -343,7 +343,7 @@ object BackupOperation {
val entry = JSONObject() val entry = JSONObject()
entry.put("label", app.label) entry.put("label", app.label)
entry.put("isSystem", app.isSystem) entry.put("isSystem", app.isSystem)
root.put(app.packageName, entry) root.put(app.packageName.value, entry)
} }
return root.toString(2) return root.toString(2)
} }

View File

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

View File

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

View File

@@ -6,6 +6,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import java.io.File import java.io.File
/** /**
@@ -19,6 +23,11 @@ import java.io.File
*/ */
class RemoteSyncManager { class RemoteSyncManager {
private sealed interface SyncEvent {
data class Phase(val progress: RemoteTransport.TransferProgress) : SyncEvent
data class Bytes(val progress: RemoteTransport.ByteProgress) : SyncEvent
}
private val TAG = "ResticWrapper" private val TAG = "ResticWrapper"
/** Local temp directory used as restic repo for SMB/WebDAV backends. */ /** Local temp directory used as restic repo for SMB/WebDAV backends. */
@@ -42,9 +51,9 @@ class RemoteSyncManager {
private fun ensureTransport( private fun ensureTransport(
backend: String, url: String, user: String, pass: String, share: String, repoPath: String backend: String, url: String, user: String, pass: String, share: String, repoPath: String
): RemoteTransport? = synchronized(transportLock) { ): RemoteTransport? = synchronized(transportLock) {
val key = "$backend|$url|$user|$pass|$share|$backendDomain|$repoPath" val key = "$backend|$url|$user|${pass.hashCode()}|$share|$backendDomain|$repoPath"
if (key != transportConfigKey || transport == null) { if (key != transportConfigKey || transport == null) {
transport?.let { Log.i(TAG, "transport config changed ($transportConfigKey -> $key), recreating") } transport?.let { Log.i(TAG, "transport config changed, recreating") }
// Clear local temp repo when backend config changes so // Clear local temp repo when backend config changes so
// syncFromRemote downloads fresh data from the new backend // syncFromRemote downloads fresh data from the new backend
if (transportConfigKey.isNotEmpty() && tempRepoDir.isNotEmpty()) { if (transportConfigKey.isNotEmpty() && tempRepoDir.isNotEmpty()) {
@@ -122,20 +131,41 @@ class RemoteSyncManager {
needsUpload: Boolean, needsUpload: Boolean,
onProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
action: suspend () -> Result<T> action: suspend () -> AppResult<T>
): Result<T> { ): AppResult<T> {
if (backend != "smb" && backend != "webdav") return action() if (backend != "smb" && backend != "webdav") return action()
return repoSyncMutex.withLock { return repoSyncMutex.withLock {
coroutineScope {
var shouldCleanup = false var shouldCleanup = false
var lastByteEmitMs = 0L
val progressChannel = Channel<SyncEvent>(CONFLATED)
val progressJob = launch(Dispatchers.Main) {
for (event in progressChannel) {
when (event) {
is SyncEvent.Phase -> onProgress(event.progress)
is SyncEvent.Bytes -> {
val now = System.currentTimeMillis()
if (now - lastByteEmitMs >= 50) {
onByteProgress(event.progress)
lastByteEmitMs = now
}
}
}
}
}
try { try {
val t = ensureTransport(backend, backendUrl, backendUser, backendPass, backendShare, repoPath) val t = ensureTransport(backend, backendUrl, backendUser, backendPass, backendShare, repoPath)
?: return@withLock Result.failure(Exception("Failed to create transport for backend: $backend")) ?: return@coroutineScope err(AppError.Remote("Failed to create transport for backend: $backend", "connecting"))
val localDir = File(tempRepoDir) val localDir = File(tempRepoDir)
val emitProgress: suspend (RemoteTransport.TransferProgress) -> Unit = { p -> val emitProgress: suspend (RemoteTransport.TransferProgress) -> Unit = { p ->
withContext(Dispatchers.Main) { onProgress(p) } progressChannel.send(SyncEvent.Phase(p))
}
val emitByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit = { p ->
progressChannel.send(SyncEvent.Bytes(p))
} }
// Write ops always download to avoid overwriting remote changes. // Write ops always download to avoid overwriting remote changes.
@@ -143,13 +173,11 @@ class RemoteSyncManager {
val actualDownload = needsDownload && (needsUpload || !isLocalRepoPopulated()) val actualDownload = needsDownload && (needsUpload || !isLocalRepoPopulated())
if (actualDownload) { if (actualDownload) {
Log.i(TAG, "syncFromRemote start: $repoPath -> $tempRepoDir") Log.i(TAG, "syncFromRemote start: $repoPath -> $tempRepoDir")
val syncResult = RemoteTransport.syncFromRemote(t, localDir, repoPath, emitProgress, onByteProgress) val syncResult = RemoteTransport.syncFromRemote(t, localDir, repoPath, emitProgress, emitByteProgress)
if (syncResult.isFailure) { if (syncResult.isFailure) {
shouldCleanup = true shouldCleanup = true
Log.e(TAG, "syncFromRemote FAILED: ${syncResult.exceptionOrNull()?.message}") Log.e(TAG, "syncFromRemote FAILED: ${syncResult.exceptionOrNull()?.message}")
return@withLock Result.failure( return@coroutineScope err(AppError.Remote("syncFromRemote failed: ${syncResult.exceptionOrNull()?.message}", "download"))
Exception("syncFromRemote failed: ${syncResult.exceptionOrNull()?.message}")
)
} }
Log.i(TAG, "syncFromRemote complete") Log.i(TAG, "syncFromRemote complete")
} else if (needsDownload) { } else if (needsDownload) {
@@ -160,13 +188,11 @@ class RemoteSyncManager {
if (needsUpload && result.isSuccess) { if (needsUpload && result.isSuccess) {
Log.i(TAG, "syncToRemote start: $tempRepoDir -> $repoPath") Log.i(TAG, "syncToRemote start: $tempRepoDir -> $repoPath")
val uploadResult = RemoteTransport.syncToRemote(t, localDir, repoPath, emitProgress, onByteProgress) val uploadResult = RemoteTransport.syncToRemote(t, localDir, repoPath, emitProgress, emitByteProgress)
if (uploadResult.isFailure) { if (uploadResult.isFailure) {
shouldCleanup = false // PRESERVE local repo — snapshot would be lost shouldCleanup = false // PRESERVE local repo — snapshot would be lost
Log.e(TAG, "syncToRemote FAILED: ${uploadResult.exceptionOrNull()?.message} — local repo preserved for retry") Log.e(TAG, "syncToRemote FAILED: ${uploadResult.exceptionOrNull()?.message} — local repo preserved for retry")
return@withLock Result.failure( return@coroutineScope err(AppError.Remote("syncToRemote failed: ${uploadResult.exceptionOrNull()?.message}", "upload"))
Exception("syncToRemote failed: ${uploadResult.exceptionOrNull()?.message}")
)
} }
Log.i(TAG, "syncToRemote complete") Log.i(TAG, "syncToRemote complete")
shouldCleanup = true shouldCleanup = true
@@ -180,8 +206,10 @@ class RemoteSyncManager {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
shouldCleanup = true shouldCleanup = true
Result.failure(e) err(AppError.Remote(e.message ?: "Unknown error", "sync", cause = e))
} finally { } finally {
progressChannel.close()
progressJob.join()
if (shouldCleanup) { if (shouldCleanup) {
Log.i(TAG, "withRemoteSync: cleaning up temp dirs") Log.i(TAG, "withRemoteSync: cleaning up temp dirs")
cleanupTempDirs() cleanupTempDirs()
@@ -191,6 +219,7 @@ class RemoteSyncManager {
} }
} }
} }
}
/** /**
* Public safety-net cleanup called by fragment lifecycle. * Public safety-net cleanup called by fragment lifecycle.

View File

@@ -4,11 +4,10 @@ import android.util.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import java.io.File import java.io.File
import kotlinx.serialization.Serializable 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). * Unified abstraction for remote file transport (SMB / WebDAV).
@@ -38,17 +37,17 @@ interface RemoteTransport {
val currentFile: String val currentFile: String
) )
suspend fun upload(localPath: String, remotePath: 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 = {}): Result<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). */ /** 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. */ /** 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 delete(remotePath: String): AppResult<Unit>
suspend fun exists(remotePath: String): Result<Boolean> suspend fun exists(remotePath: String): AppResult<Boolean>
companion object { companion object {
private const val TAG = "RemoteTransport" private const val TAG = "RemoteTransport"
@@ -79,9 +78,9 @@ interface RemoteTransport {
*/ */
private suspend fun <T> withRetry( private suspend fun <T> withRetry(
tag: String, tag: String,
block: suspend () -> Result<T> block: suspend () -> AppResult<T>
): Result<T> { ): AppResult<T> {
var lastError: Result<T>? = null var lastError: AppResult<T>? = null
for (attempt in 0..MAX_RETRIES) { for (attempt in 0..MAX_RETRIES) {
if (attempt > 0) { if (attempt > 0) {
val waitMs = 1000L * (1 shl (attempt - 1)) // 1s, 2s, 4s val waitMs = 1000L * (1 shl (attempt - 1)) // 1s, 2s, 4s
@@ -97,7 +96,7 @@ interface RemoteTransport {
} }
return result // permanent error — don't retry return result // permanent error — don't retry
} }
return lastError ?: Result.failure(Exception("$tag: max retries exceeded")) return lastError ?: err(AppError.Remote("$tag: max retries exceeded", "retry"))
} }
fun create( fun create(
@@ -133,7 +132,7 @@ interface RemoteTransport {
remoteDir: String, remoteDir: String,
onProgress: suspend (TransferProgress) -> Unit = {}, onProgress: suspend (TransferProgress) -> Unit = {},
onByteProgress: suspend (ByteProgress) -> Unit = {} onByteProgress: suspend (ByteProgress) -> Unit = {}
): Result<Unit> = withContext(Dispatchers.IO) { ): AppResult<Unit> = withContext(Dispatchers.IO) {
try { try {
localDir.mkdirs() localDir.mkdirs()
val remoteFiles = listRemoteRecursive(transport, remoteDir) val remoteFiles = listRemoteRecursive(transport, remoteDir)
@@ -141,7 +140,7 @@ interface RemoteTransport {
// This is normal for first-time init where the repo doesn't exist yet. // This is normal for first-time init where the repo doesn't exist yet.
if (remoteFiles == null) { if (remoteFiles == null) {
Log.w(TAG, "syncFromRemote: remote dir '$remoteDir' not accessible, treating as empty") Log.w(TAG, "syncFromRemote: remote dir '$remoteDir' not accessible, treating as empty")
return@withContext Result.success(Unit) return@withContext AppResult.Success(Unit)
} }
onProgress(TransferProgress("list", 0, remoteFiles.size)) onProgress(TransferProgress("list", 0, remoteFiles.size))
val remoteByPath = remoteFiles.associateBy { it.path } val remoteByPath = remoteFiles.associateBy { it.path }
@@ -174,9 +173,7 @@ interface RemoteTransport {
// If any download failed, abort before deleting local files — // If any download failed, abort before deleting local files —
// deleting would destroy valid data for an incomplete sync. // deleting would destroy valid data for an incomplete sync.
if (errors.isNotEmpty()) { if (errors.isNotEmpty()) {
return@withContext Result.failure( return@withContext err(AppError.Remote("syncFromRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}", "sync"))
Exception("syncFromRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}")
)
} }
// Delete local files not on remote (e.g. after prune on another client) // Delete local files not on remote (e.g. after prune on another client)
@@ -210,7 +207,7 @@ interface RemoteTransport {
remoteDir: String, remoteDir: String,
onProgress: suspend (TransferProgress) -> Unit = {}, onProgress: suspend (TransferProgress) -> Unit = {},
onByteProgress: suspend (ByteProgress) -> Unit = {} onByteProgress: suspend (ByteProgress) -> Unit = {}
): Result<Unit> = withContext(Dispatchers.IO) { ): AppResult<Unit> = withContext(Dispatchers.IO) {
try { try {
val localFiles = walkLocalFiles(localDir) val localFiles = walkLocalFiles(localDir)
onProgress(TransferProgress("list", 0, localFiles.size)) onProgress(TransferProgress("list", 0, localFiles.size))
@@ -261,9 +258,7 @@ interface RemoteTransport {
// If any upload failed, abort before deleting remote files — // If any upload failed, abort before deleting remote files —
// deleting during failed sync could lose the only copy on remote. // deleting during failed sync could lose the only copy on remote.
if (errors.isNotEmpty()) { if (errors.isNotEmpty()) {
return@withContext Result.failure( return@withContext err(AppError.Remote("syncToRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}", "sync"))
Exception("syncToRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}")
)
} }
// Delete remote files no longer present locally // Delete remote files no longer present locally
@@ -275,9 +270,11 @@ interface RemoteTransport {
transport.delete("$remoteDir/$relPath") transport.delete("$remoteDir/$relPath")
} }
onProgress(TransferProgress("complete", uploaded, syncTotal, "已传输: $uploaded 跳过: $uploadSkipped")) onProgress(TransferProgress("complete", uploaded, syncTotal, "已传输: $uploaded 跳过: $uploadSkipped"))
Result.success(Unit) AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(Exception("syncToRemote failed: ${e.message}", e)) err(AppError.Remote("syncToRemote failed: ${e.message}", "sync", cause = e))
} }
} }
@@ -310,10 +307,10 @@ interface RemoteTransport {
transport.listFiles(fullDir) transport.listFiles(fullDir)
} }
if (listResult.isFailure) { if (listResult.isFailure) {
val err = listResult.exceptionOrNull() val err = listResult.errorOrNull()
// 404 on a subdirectory: directory doesn't exist, skip it silently. // 404 on a subdirectory: directory doesn't exist, skip it silently.
// 404 on the root directory: fatal — the remote repo path may be wrong. // 404 on the root directory: fatal — the remote repo path may be wrong.
if (err is FileNotFoundException) { if (err?.isFileNotFound() == true) {
if (subDir.isEmpty()) { if (subDir.isEmpty()) {
Log.e(TAG, "listRemoteRecursive: root dir '$fullDir' returned 404 — repo may not exist or is rate-limited") Log.e(TAG, "listRemoteRecursive: root dir '$fullDir' returned 404 — repo may not exist or is rate-limited")
return null return null
@@ -367,3 +364,7 @@ interface RemoteTransport {
} }
} }
} }
/** Extension to check if an [AppError] represents a "not found" remote error. */
private fun AppError.isFileNotFound(): Boolean =
this is AppError.Remote && this.isNotFound

View File

@@ -6,6 +6,9 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlin.coroutines.coroutineContext 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. */ /** Shared Json instance configured for restic's snake_case output via @SerialName. */
private val resticJson = Json { ignoreUnknownKeys = true } private val resticJson = Json { ignoreUnknownKeys = true }
@@ -21,7 +24,7 @@ class ResticBackup(
private val envResolver: ResticEnvResolver, private val envResolver: ResticEnvResolver,
private val syncManager: RemoteSyncManager private val syncManager: RemoteSyncManager
) { ) {
private val TAG = "ResticWrapper" private val TAG = "ResticBackup"
// ── Backup ───────────────────────────────────────── // ── Backup ─────────────────────────────────────────
@@ -39,7 +42,7 @@ class ResticBackup(
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
onProgress: suspend (ResticWrapper.ResticProgress) -> 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) } } val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = true, needsDownload = true, needsUpload = true,
@@ -61,7 +64,7 @@ class ResticBackup(
} }
if (result.exitCode != 0) { if (result.exitCode != 0) {
return@withRemoteSync Result.failure(Exception("restic backup failed: ${result.stderr}")) return@withRemoteSync err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
} }
parseBackupSummary(result.stdout) parseBackupSummary(result.stdout)
@@ -71,16 +74,16 @@ class ResticBackup(
// ── Internal helpers ─────────────────────────────── // ── Internal helpers ───────────────────────────────
/** Parse the JSON summary from the end of restic backup output. */ /** 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() val lines = stdout.lines()
for (i in lines.indices.reversed()) { for (i in lines.indices.reversed()) {
val line = lines[i].trim() val line = lines[i].trim()
if (!line.startsWith("{")) continue if (!line.startsWith("{")) continue
try { try {
val summary = resticJson.decodeFromString<ResticWrapper.BackupSummary>(line) val summary = resticJson.decodeFromString<ResticWrapper.BackupSummary>(line)
if (summary.messageType == "summary" && summary.snapshotId.isNotEmpty()) return Result.success(summary) if (summary.messageType == "summary" && summary.snapshotId.isNotEmpty()) return AppResult.Success(summary)
} catch (_: Exception) { /* keep looking */ } } catch (_: Exception) { /* keep looking */ }
} }
return Result.failure(Exception("No summary found in restic output")) return err(AppError.Parse("restic 备份输出未找到摘要信息", "stdout=" + stdout.length))
} }
} }

View File

@@ -41,5 +41,5 @@ object ResticBinary {
return dir.absolutePath return dir.absolutePath
} }
fun isReady(): Boolean = false // call prepare() instead fun isReady(): Boolean = cachedBinaryPath != null
} }

View File

@@ -3,6 +3,7 @@ package com.example.androidbackupgui.backup
import android.util.Log import android.util.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import com.example.androidbackupgui.backup.AppError
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.BufferedReader import java.io.BufferedReader
import java.io.File import java.io.File
@@ -28,10 +29,9 @@ class ResticCommandRunner {
) )
/** Build the full command list to run restic. */ /** Build the full command list to run restic. */
fun buildCommandArgs(args: List<String>): List<String> { fun buildCommandArgs(args: List<String>): List<String> =
val cmd = listOf(binaryPath) + args (listOf(binaryPath) + args).also { cmd ->
Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args cmd=$cmd") Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args -> cmd=$cmd")
return cmd
} }
/** Run restic (non-streaming). */ /** Run restic (non-streaming). */
@@ -39,6 +39,8 @@ class ResticCommandRunner {
val cmdArgs = buildCommandArgs(args) val cmdArgs = buildCommandArgs(args)
Log.i(TAG, "runRestic cmd=${cmdArgs.joinToString(" ")}") Log.i(TAG, "runRestic cmd=${cmdArgs.joinToString(" ")}")
Log.d(TAG, "runRestic REPOSITORY=${env["RESTIC_REPOSITORY"]}") 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() } env["TMPDIR"]?.let { File(it).mkdirs() }
return try { return try {
val pb = ProcessBuilder(cmdArgs) val pb = ProcessBuilder(cmdArgs)
@@ -46,25 +48,15 @@ class ResticCommandRunner {
pb.redirectErrorStream(false) pb.redirectErrorStream(false)
val process = pb.start() 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 stdout = process.inputStream.bufferedReader().use(BufferedReader::readText)
val exitCode = process.waitFor() val exitCode = process.waitFor()
stderrThread.join(5000) val stderrBytes = process.errorStream.readAllBytes()
val stderrText = stderrBytes.decodeToString()
Log.i(TAG, "runRestic exitCode=$exitCode stdout_len=${stdout.length}") Log.i(TAG, "runRestic exitCode=$exitCode stdout_len=${stdout.length}")
if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText}") if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText.trim()}")
CommandResult(stdout.trim(), stderrText.toString().trim(), exitCode) CommandResult(stdout.trim(), stderrText.trim(), exitCode)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "runRestic exception", e) Log.e(TAG, "runRestic exception", e)
CommandResult("", e.message ?: "Unknown error", -1) CommandResult("", e.message ?: "Unknown error", -1)
@@ -136,7 +128,9 @@ class ResticCommandRunner {
Log.i(TAG, "runResticStreaming exitCode=$exitCode stdout_len=${stdoutText.length}") Log.i(TAG, "runResticStreaming exitCode=$exitCode stdout_len=${stdoutText.length}")
if (stderrText.isNotEmpty()) Log.w(TAG, "runResticStreaming stderr: ${stderrText}") 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) { } catch (e: Exception) {
Log.e(TAG, "runResticStreaming exception", e) Log.e(TAG, "runResticStreaming exception", e)
try { process?.destroy() } catch (_: Exception) {} try { process?.destroy() } catch (_: Exception) {}

View File

@@ -2,6 +2,9 @@ package com.example.androidbackupgui.backup
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import com.example.androidbackupgui.backup.AppError
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.err
/** /**
* Repository maintenance operations: prune, check, stats. * Repository maintenance operations: prune, check, stats.
@@ -28,7 +31,7 @@ class ResticMaintenance(
backendShare: String = "", backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> = ): AppResult<String> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = true, needsDownload = true, needsUpload = true,
@@ -37,8 +40,8 @@ class ResticMaintenance(
) { ) {
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
val result = runner.runRestic(env, "prune") val result = runner.runRestic(env, "prune")
if (result.exitCode == 0) Result.success(result.stdout) if (result.exitCode == 0) AppResult.Success(result.stdout)
else Result.failure(Exception("restic prune failed: ${result.stderr}")) else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr))
} }
} }
@@ -54,7 +57,7 @@ class ResticMaintenance(
backendShare: String = "", backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> = ): AppResult<String> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = false, needsDownload = true, needsUpload = false,
@@ -63,8 +66,8 @@ class ResticMaintenance(
) { ) {
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
val result = runner.runRestic(env, "check") val result = runner.runRestic(env, "check")
if (result.exitCode == 0) Result.success(result.stdout) if (result.exitCode == 0) AppResult.Success(result.stdout)
else Result.failure(Exception("restic check failed: ${result.stderr}")) else err(AppError.Restic("restic check 失败", result.exitCode, result.stderr))
} }
} }
@@ -80,7 +83,7 @@ class ResticMaintenance(
backendShare: String = "", backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> = ): AppResult<String> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = false, needsDownload = true, needsUpload = false,
@@ -89,8 +92,8 @@ class ResticMaintenance(
) { ) {
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
val result = runner.runRestic(env, "stats") val result = runner.runRestic(env, "stats")
if (result.exitCode == 0) Result.success(result.stdout) if (result.exitCode == 0) AppResult.Success(result.stdout)
else Result.failure(Exception("restic stats failed: ${result.stderr}")) else err(AppError.Restic("restic stats 失败", result.exitCode, result.stderr))
} }
} }
} }

View File

@@ -3,6 +3,9 @@ package com.example.androidbackupgui.backup
import android.util.Log import android.util.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import com.example.androidbackupgui.backup.AppError
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.err
/** /**
* Repository lifecycle operations: init and repo URL construction. * Repository lifecycle operations: init and repo URL construction.
@@ -29,7 +32,7 @@ class ResticRepoInit(
backendShare: String = "", backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<Unit> = ): AppResult<Unit> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = true, needsDownload = true, needsUpload = true,
@@ -40,7 +43,7 @@ class ResticRepoInit(
val result = runner.runRestic(env, "init") val result = runner.runRestic(env, "init")
// exitCode 0 = brand new repo created, needs upload // exitCode 0 = brand new repo created, needs upload
if (result.exitCode == 0) { if (result.exitCode == 0) {
return@withRemoteSync Result.success(Unit) return@withRemoteSync AppResult.Success(Unit)
} }
// exitCode 1 = config already exists; verify the repo is actually usable // exitCode 1 = config already exists; verify the repo is actually usable
if (result.exitCode == 1) { if (result.exitCode == 1) {
@@ -48,14 +51,14 @@ class ResticRepoInit(
if (verify.exitCode == 0) { if (verify.exitCode == 0) {
// Repo is healthy — already initialized with matching password // Repo is healthy — already initialized with matching password
Log.i(TAG, "init: repo already initialized and verified") Log.i(TAG, "init: repo already initialized and verified")
return@withRemoteSync Result.success(Unit) return@withRemoteSync AppResult.Success(Unit)
} }
// Config exists but repo is corrupted (wrong password, missing keys, etc.) // Config exists but repo is corrupted (wrong password, missing keys, etc.)
return@withRemoteSync Result.failure( return@withRemoteSync err(
Exception("仓库已存在但无法验证: ${verify.stderr.ifEmpty { "密码错误或密钥缺失" }}。请删除远端仓库后重试。") AppError.Restic("仓库已存在但无法验证", verify.exitCode, verify.stderr)
) )
} }
Result.failure(Exception("restic init failed: ${result.stderr}")) err(AppError.Restic("restic init 失败", result.exitCode, result.stderr))
} }
} }

View File

@@ -6,6 +6,9 @@ import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlin.coroutines.coroutineContext 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. */ /** Shared Json instance configured for restic's snake_case output via @SerialName. */
private val resticJson = Json { ignoreUnknownKeys = true } private val resticJson = Json { ignoreUnknownKeys = true }
@@ -38,7 +41,7 @@ class ResticRestore(
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
onProgress: suspend (String) -> 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) } } val emit: suspend (String) -> Unit = { s -> withContext(Dispatchers.Main) { onProgress(s) } }
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = false, needsDownload = true, needsUpload = false,
@@ -67,8 +70,8 @@ class ResticRestore(
} catch (_: Exception) { emit(line) } } catch (_: Exception) { emit(line) }
} }
if (result.exitCode == 0) Result.success(Unit) if (result.exitCode == 0) AppResult.Success(Unit)
else Result.failure(Exception("restic restore failed: ${result.stderr}")) else err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr))
} }
} }
@@ -86,7 +89,7 @@ class ResticRestore(
backendShare: String = "", backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> = withContext(Dispatchers.IO) { ): AppResult<String> = withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = false, needsDownload = true, needsUpload = false,
onProgress = onSyncProgress, onProgress = onSyncProgress,
@@ -94,8 +97,8 @@ class ResticRestore(
) { ) {
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
val result = runner.runRestic(env, "dump", snapshotId, filePath) val result = runner.runRestic(env, "dump", snapshotId, filePath)
if (result.exitCode == 0) Result.success(result.stdout) if (result.exitCode == 0) AppResult.Success(result.stdout)
else Result.failure(Exception(result.stderr.ifEmpty { "restic dump failed with exit code ${result.exitCode}" })) else err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))
} }
} }
} }

View File

@@ -3,6 +3,9 @@ package com.example.androidbackupgui.backup
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
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. */ /** Shared Json instance configured for restic's snake_case output via @SerialName. */
@@ -33,7 +36,7 @@ class ResticSnapshotOps(
backendShare: String = "", backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<List<ResticWrapper.ResticSnapshot>> = withContext(Dispatchers.IO) { ): AppResult<List<ResticWrapper.ResticSnapshot>> = withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = false, needsDownload = true, needsUpload = false,
onProgress = onSyncProgress, onProgress = onSyncProgress,
@@ -46,16 +49,16 @@ class ResticSnapshotOps(
val result = runner.runRestic(env, args) val result = runner.runRestic(env, args)
if (result.exitCode != 0) { if (result.exitCode != 0) {
return@withRemoteSync Result.failure(Exception("restic snapshots failed: ${result.stderr}")) return@withRemoteSync err(AppError.Restic("restic snapshots 失败", result.exitCode, result.stderr))
} }
try { try {
val snapshots = resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>( val snapshots = resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
result.stdout.ifEmpty { "[]" } result.stdout.ifEmpty { "[]" }
) )
Result.success(snapshots.sortedByDescending { it.time }) AppResult.Success(snapshots.sortedByDescending { it.time })
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(Exception("Failed to parse snapshot JSON: ${e.message}")) err(AppError.Parse("解析快照 JSON 失败", e.message ?: ""))
} }
} }
} }
@@ -76,7 +79,7 @@ class ResticSnapshotOps(
backendShare: String = "", backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> = withContext(Dispatchers.IO) { ): AppResult<String> = withContext(Dispatchers.IO) {
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
needsDownload = true, needsUpload = true, needsDownload = true, needsUpload = true,
onProgress = onSyncProgress, onProgress = onSyncProgress,
@@ -93,8 +96,8 @@ class ResticSnapshotOps(
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
val result = runner.runRestic(env, args) val result = runner.runRestic(env, args)
if (result.exitCode == 0) Result.success(result.stdout) if (result.exitCode == 0) AppResult.Success(result.stdout)
else Result.failure(Exception("restic forget failed: ${result.stderr}")) else err(AppError.Restic("restic forget 失败", result.exitCode, result.stderr))
} }
} }
} }

View File

@@ -8,6 +8,9 @@ import kotlinx.coroutines.withContext
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName 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. * Wraps the restic CLI binary for backup/restore operations.
@@ -93,7 +96,7 @@ object ResticWrapper {
backendShare: String = "", backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<Unit> = repoInit.init( ): AppResult<Unit> = repoInit.init(
repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress onSyncProgress, onByteSyncProgress
) )
@@ -132,7 +135,7 @@ object ResticWrapper {
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
onProgress: suspend (ResticProgress) -> Unit = {} onProgress: suspend (ResticProgress) -> Unit = {}
): Result<BackupSummary> = backupOp.backup( ): AppResult<BackupSummary> = backupOp.backup(
repoPath, password, paths, tags, hostname, repoPath, password, paths, tags, hostname,
backend, backendUrl, backendUser, backendPass, backendShare, backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress, onProgress onSyncProgress, onByteSyncProgress, onProgress
@@ -154,7 +157,7 @@ object ResticWrapper {
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
onProgress: suspend (String) -> Unit = {} onProgress: suspend (String) -> Unit = {}
): Result<Unit> = restoreOp.restore( ): AppResult<Unit> = restoreOp.restore(
repoPath, password, snapshotId, targetPath, include, repoPath, password, snapshotId, targetPath, include,
backend, backendUrl, backendUser, backendPass, backendShare, backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress, onProgress onSyncProgress, onByteSyncProgress, onProgress
@@ -174,7 +177,7 @@ object ResticWrapper {
backendShare: String = "", backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> = restoreOp.dump( ): AppResult<String> = restoreOp.dump(
repoPath, password, snapshotId, filePath, repoPath, password, snapshotId, filePath,
backend, backendUrl, backendUser, backendPass, backendShare, backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress onSyncProgress, onByteSyncProgress
@@ -193,7 +196,7 @@ object ResticWrapper {
backendShare: String = "", backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<List<ResticSnapshot>> = snapshotOps.listSnapshots( ): AppResult<List<ResticSnapshot>> = snapshotOps.listSnapshots(
repoPath, password, tag, repoPath, password, tag,
backend, backendUrl, backendUser, backendPass, backendShare, backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress onSyncProgress, onByteSyncProgress
@@ -213,7 +216,7 @@ object ResticWrapper {
backendShare: String = "", backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> = snapshotOps.forget( ): AppResult<String> = snapshotOps.forget(
repoPath, password, keepDaily, keepWeekly, keepMonthly, dryRun, repoPath, password, keepDaily, keepWeekly, keepMonthly, dryRun,
backend, backendUrl, backendUser, backendPass, backendShare, backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress onSyncProgress, onByteSyncProgress
@@ -231,7 +234,7 @@ object ResticWrapper {
backendShare: String = "", backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> = maintenance.prune( ): AppResult<String> = maintenance.prune(
repoPath, password, repoPath, password,
backend, backendUrl, backendUser, backendPass, backendShare, backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress onSyncProgress, onByteSyncProgress
@@ -247,7 +250,7 @@ object ResticWrapper {
backendShare: String = "", backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> = maintenance.check( ): AppResult<String> = maintenance.check(
repoPath, password, repoPath, password,
backend, backendUrl, backendUser, backendPass, backendShare, backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress onSyncProgress, onByteSyncProgress
@@ -263,7 +266,7 @@ object ResticWrapper {
backendShare: String = "", backendShare: String = "",
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
): Result<String> = maintenance.stats( ): AppResult<String> = maintenance.stats(
repoPath, password, repoPath, password,
backend, backendUrl, backendUser, backendPass, backendShare, backend, backendUrl, backendUser, backendPass, backendShare,
onSyncProgress, onByteSyncProgress onSyncProgress, onByteSyncProgress

View File

@@ -1,6 +1,7 @@
package com.example.androidbackupgui.backup package com.example.androidbackupgui.backup
import com.example.androidbackupgui.root.RootShell import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape import com.example.androidbackupgui.root.shellEscape
import android.content.Context
import android.util.Log import android.util.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@@ -42,6 +43,7 @@ object RestoreOperation {
* @param filterPkgs if non-null, only restore packages in this set * @param filterPkgs if non-null, only restore packages in this set
*/ */
suspend fun restoreApps( suspend fun restoreApps(
context: Context,
backupDir: File, backupDir: File,
userId: String = "0", userId: String = "0",
filterPkgs: Set<String>? = null, filterPkgs: Set<String>? = null,
@@ -50,6 +52,11 @@ object RestoreOperation {
val emit: suspend (RestoreProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } } val emit: suspend (RestoreProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
val startTime = System.currentTimeMillis() 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 // Read app list from backup
val appListFile = File(backupDir, "appList.txt") val appListFile = File(backupDir, "appList.txt")
val allPackages = if (appListFile.exists()) { val allPackages = if (appListFile.exists()) {
@@ -88,7 +95,7 @@ object RestoreOperation {
// 1. Install APK // 1. Install APK
emit(RestoreProgress(index + 1, packages.size, pkg, "install", "正在安装 APK…")) emit(RestoreProgress(index + 1, packages.size, pkg, "install", "正在安装 APK…"))
val installed = installApk(appBackupDir) val installed = installApk(pkg, appBackupDir)
if (!installed) { if (!installed) {
failAtomic.incrementAndGet() failAtomic.incrementAndGet()
@@ -101,11 +108,11 @@ object RestoreOperation {
// 3. Restore data // 3. Restore data
emit(RestoreProgress(index + 1, packages.size, pkg, "data", "正在恢复数据…")) emit(RestoreProgress(index + 1, packages.size, pkg, "data", "正在恢复数据…"))
restoreData(appBackupDir) restoreData(pkg, userId, appBackupDir, tarCmd, zstdCmd)
// 4. Restore OBB // 4. Restore OBB
emit(RestoreProgress(index + 1, packages.size, pkg, "obb", "正在恢复 OBB…")) emit(RestoreProgress(index + 1, packages.size, pkg, "obb", "正在恢复 OBB…"))
restoreObb(pkg, appBackupDir) restoreObb(pkg, appBackupDir, tarCmd, zstdCmd)
// 5. Restore SSAID // 5. Restore SSAID
emit(RestoreProgress(index + 1, packages.size, pkg, "ssaid", "正在恢复 SSAID…")) emit(RestoreProgress(index + 1, packages.size, pkg, "ssaid", "正在恢复 SSAID…"))
@@ -132,7 +139,7 @@ object RestoreOperation {
RestoreResult(successCount, failCount, elapsed) RestoreResult(successCount, failCount, elapsed)
} }
private suspend fun installApk(appDir: File): Boolean { private suspend fun installApk(packageName: String, appDir: File): Boolean {
// Find APK files // Find APK files
val apkFiles = appDir.listFiles() val apkFiles = appDir.listFiles()
?.filter { it.name.endsWith(".apk") } ?.filter { it.name.endsWith(".apk") }
@@ -141,6 +148,7 @@ object RestoreOperation {
if (apkFiles.isEmpty()) return false if (apkFiles.isEmpty()) return false
suspend fun doInstall(): Boolean {
// Build install command for multiple APKs (split APK support) // Build install command for multiple APKs (split APK support)
val apkPaths = apkFiles.joinToString(" ") { "'${it.absolutePath.shellEscape()}'" } val apkPaths = apkFiles.joinToString(" ") { "'${it.absolutePath.shellEscape()}'" }
@@ -167,7 +175,41 @@ object RestoreOperation {
return result.isSuccess return result.isSuccess
} }
private suspend fun restoreData(appDir: File) { 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(packageName: String, userId: String, appDir: File, tarCmd: String, zstdCmd: String) {
val files = appDir.listFiles() val files = appDir.listFiles()
if (files.isNullOrEmpty()) { if (files.isNullOrEmpty()) {
Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}") Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}")
@@ -178,27 +220,60 @@ object RestoreOperation {
Log.w(TAG, "restoreData: no _data.tar in ${appDir.name}, found: ${files.map { it.name }}") Log.w(TAG, "restoreData: no _data.tar in ${appDir.name}, found: ${files.map { it.name }}")
return 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) { for (archive in dataFiles) {
val archivePath = archive.absolutePath.shellEscape() val archivePath = archive.absolutePath.shellEscape()
Log.d(TAG, "restoreData: found archive ${archive.name}") Log.d(TAG, "restoreData: found archive ${archive.name}")
if (!isArchiveSafe(archive)) { if (!isArchiveSafe(archive, zstdCmd)) {
Log.w(TAG, "restoreData: archive NOT SAFE, skipping: ${archive.name}") Log.w(TAG, "restoreData: archive NOT SAFE, skipping: ${archive.name}")
continue continue
} }
val cmd = when {
// Build the extract command with exclusion flags
val baseCmd = when {
archive.name.endsWith(".zst") -> archive.name.endsWith(".zst") ->
"zstd -d -c '$archivePath' | tar -xf - -C / 2>/dev/null" "set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null"
archive.name.endsWith(".gz") -> archive.name.endsWith(".gz") ->
"tar -xzf '$archivePath' -C / 2>/dev/null" "$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null"
archive.name.endsWith(".tar") -> archive.name.endsWith(".tar") ->
"tar -xf '$archivePath' -C / 2>/dev/null" "$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null"
else -> { Log.w(TAG, "restoreData: unknown archive type ${archive.name}"); continue } else -> { Log.w(TAG, "restoreData: unknown archive type ${archive.name}"); continue }
} }
val result = RootShell.exec(cmd)
val result = RootShell.exec(baseCmd)
if (result.isSuccess) { if (result.isSuccess) {
Log.i(TAG, "restoreData: extracted ${archive.name}") Log.i(TAG, "restoreData: extracted ${archive.name}")
} else { } else {
Log.e(TAG, "restoreData: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}") Log.e(TAG, "restoreData: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}")
// Continue to try SELinux fix even if extraction had issues
}
}
// Restore SELinux context on extracted data directories
for (dataPath in dataPaths) {
// Try to get the existing context (if the path already existed)
val existingContext = SELinuxUtil.getContext(dataPath)
val context = existingContext ?: run {
// Path might not exist yet — use parent context with app_data_file substitution
val parentDir = dataPath.substringBeforeLast("/")
val parentContext = SELinuxUtil.getContext(parentDir)
parentContext?.replace("system_data_file", "app_data_file")
}
if (context != null) {
Log.d(TAG, "restoreData: restoring SELinux context on $dataPath: $context")
SELinuxUtil.chcon(context, dataPath)
} else {
Log.w(TAG, "restoreData: could not determine SELinux context for $dataPath")
} }
} }
} }
@@ -208,13 +283,18 @@ object RestoreOperation {
* or symbolic links pointing outside the tree. * or symbolic links pointing outside the tree.
* Accepts both absolute and relative paths — tar implementations vary. * 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")) { 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 { } else {
"tar tf '${archive.absolutePath.shellEscape()}' 2>/dev/null" "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 if (!result.isSuccess) return false
return !result.output.lines().any { line -> return !result.output.lines().any { line ->
val path = line.substringBefore(" -> ") val path = line.substringBefore(" -> ")
@@ -226,29 +306,39 @@ object RestoreOperation {
} }
} }
private suspend fun restoreObb(packageName: String, appDir: File) { private suspend fun restoreObb(packageName: String, appDir: File, tarCmd: String, zstdCmd: String) {
val obbFiles = appDir.listFiles() val obbFiles = appDir.listFiles()
?.filter { it.name.contains("_obb.tar") } ?.filter { it.name.contains("_obb.tar") }
?: return ?: 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) { for (archive in obbFiles) {
if (!isArchiveSafe(archive)) continue if (!isArchiveSafe(archive, zstdCmd)) continue
val archivePath = archive.absolutePath.shellEscape() val archivePath = archive.absolutePath.shellEscape()
when { when {
archive.name.endsWith(".zst") -> { 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") -> { 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") -> { 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 // Fix OBB permissions: resolve GID from parent directory instead of hardcoding 1023
RootShell.exec("chown -R 1023:1023 /storage/emulated/0/Android/obb/${packageName.shellEscape()}/ 2>/dev/null") 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) { private suspend fun restoreSsaid(packageName: String, appDir: File, userId: String) {
@@ -267,23 +357,60 @@ object RestoreOperation {
.trim() .trim()
.toIntOrNull() .toIntOrNull()
if (uid != null) { if (uid == null) {
// Use settings put secure to set SSAID (more reliable than XML manipulation) Log.w(TAG, "restoreSsaid: could not resolve UID for $packageName")
val result = RootShell.exec("settings put secure ssaid_$uid '$ssaidValue'") return
if (result.isSuccess) {
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName (uid=$uid)")
} else {
Log.w(TAG, "restoreSsaid: failed to set SSAID for $packageName: ${result.error}")
} }
} else {
Log.w(TAG, "restoreSsaid: could not resolve UID for $packageName, falling back to XML edit") // Try XML-based approach first (more reliable across Android versions)
// Fallback: edit settings_ssaid.xml directly
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml" val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
RootShell.exec( val xmlSuccess = run {
"grep -v '${packageName.shellEscape()}' '$targetFile' > '$targetFile.tmp' && " + // Check if file exists
"sed -i '\$ i ${ssaidValue.shellEscape()}' '$targetFile.tmp' && " + val checkResult = RootShell.exec("test -f '$targetFile' && echo 'exists'")
"mv '$targetFile.tmp' '$targetFile'" 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}")
}
} }
} }
@@ -291,43 +418,109 @@ object RestoreOperation {
val permFile = File(appDir, "permissions.txt") val permFile = File(appDir, "permissions.txt")
if (!permFile.exists()) return if (!permFile.exists()) return
// dumpsys 输出格式: "android.permission.XXX: granted=true" 或 "permission.XXX: granted=true" // Parse permissions from dumpsys output.
// 各 Android 版本输出有差异try-catch 兜底避免单权限失败中断全部 // Format: "android.permission.XXX: granted=true" or "android.permission.XXX: granted=false"
val perms = try { val parsedPerms = try {
permFile.readLines() permFile.readLines().mapNotNull { line ->
.filter { it.contains("granted=true") } val name = line.substringBefore(":").trim().takeIf { it.isNotEmpty() && it.contains(".") } ?: return@mapNotNull null
.mapNotNull { line -> val granted = line.contains("granted=true")
line.substringBefore(":") Pair(name, granted)
.trim()
.takeIf { it.isNotEmpty() && it.contains(".") }
} }
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
if (parsedPerms.isEmpty()) return
val pkgEsc = packageName.shellEscape() val pkgEsc = packageName.shellEscape()
for (perm in perms) {
// Reset app ops first (clears any previous modes)
RootShell.exec("appops reset '$pkgEsc' 2>/dev/null")
val grantedPerms = parsedPerms.filter { it.second }.map { it.first }
val deniedPerms = parsedPerms.filter { !it.second }.map { it.first }
// Grant runtime permissions that were previously granted
for (perm in grantedPerms) {
val result = RootShell.exec("pm grant '$pkgEsc' '${perm.shellEscape()}' 2>&1") val result = RootShell.exec("pm grant '$pkgEsc' '${perm.shellEscape()}' 2>&1")
if (!result.isSuccess) { if (!result.isSuccess) {
android.util.Log.w("RestoreOperation", "pm grant failed for $packageName: $perm${result.output}") Log.w(TAG, "restorePermissions: pm grant failed for $packageName: $perm${result.output}")
}
} }
} }
private suspend fun fixDataOwnership(packageName: String, userId: String) { // 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() val pkgEsc = packageName.shellEscape()
val uidEsc = userId.shellEscape() // Method 1: pm list packages -U (reliable, consistent output format)
val uidResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1") val pmResult = RootShell.exec("pm list packages -U 2>/dev/null | grep '${pkgEsc}$'")
val uid = uidResult.output 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=", "") .substringAfter("userId=", "")
.substringBefore(" ") .substringBefore(" ")
.substringBefore(",") .substringBefore(",")
.trim() .trim()
.toIntOrNull() .toIntOrNull()
if (dsUid != null) return dsUid
if (uid != null) { // Method 3: dumpsys with userId: separator (AOSP variant)
RootShell.exec("chown -R $uid:$uid /data/data/$pkgEsc/ 2>/dev/null") val ds2Result = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId:' | head -1")
RootShell.exec("chown -R $uid:$uid /data/user_de/$uidEsc/$pkgEsc/ 2>/dev/null") val ds2Uid = ds2Result.output
RootShell.exec("restorecon -R /data/data/$pkgEsc/ 2>/dev/null") .substringAfter("userId:", "")
RootShell.exec("restorecon -R /data/user_de/$uidEsc/$pkgEsc/ 2>/dev/null") .substringBefore(" ")
.trim()
.toIntOrNull()
return ds2Uid
}
private suspend fun fixDataOwnership(packageName: String, userId: String) {
val pkgEsc = packageName.shellEscape()
val uidEsc = userId.shellEscape()
val uid = resolveAppUid(packageName)
if (uid == null) {
Log.w(TAG, "fixDataOwnership: could not resolve UID for $packageName — data will be inaccessible")
return
}
// USER and USER_DE use uid:uid (app's own group)
val dataPaths = listOf(
"/data/data/$pkgEsc",
"/data/user_de/$uidEsc/$pkgEsc"
)
for (dataPath in dataPaths) {
RootShell.exec("chown -R $uid:$uid '$dataPath/' 2>/dev/null")
// Restore SELinux context instead of using restorecon (which applies defaults)
val existingContext = SELinuxUtil.getContext(dataPath)
val context = existingContext ?: run {
val parentDir = dataPath.substringBeforeLast("/")
val parentContext = SELinuxUtil.getContext(parentDir)
parentContext?.replace("system_data_file", "app_data_file")
}
if (context != null) {
SELinuxUtil.chcon(context, dataPath)
Log.d(TAG, "fixDataOwnership: restored SELinux context on $dataPath: $context")
} else {
Log.w(TAG, "fixDataOwnership: could not determine SELinux context for $dataPath")
}
} }
} }
} }

View File

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

View File

@@ -11,6 +11,7 @@ import jcifs.smb.SmbFileInputStream
import jcifs.smb.SmbFileOutputStream import jcifs.smb.SmbFileOutputStream
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import java.io.File import java.io.File
import java.util.Properties import java.util.Properties
@@ -54,7 +55,7 @@ class SmbTransport(
private fun smbFile(path: String): SmbFile = SmbFile(buildUrl(path), context) 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) { withContext(Dispatchers.IO) {
try { try {
val localFile = File(localPath) val localFile = File(localPath)
@@ -83,14 +84,16 @@ class SmbTransport(
} }
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath)) onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
Log.i(TAG, "upload $localPath -> ${buildUrl(remotePath)} ($fileSize bytes)") Log.i(TAG, "upload $localPath -> ${buildUrl(remotePath)} ($fileSize bytes)")
Result.success(Unit) AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "upload failed: ${buildUrl(remotePath)}", e) 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) { withContext(Dispatchers.IO) {
try { try {
val localFile = File(localPath) val localFile = File(localPath)
@@ -114,19 +117,21 @@ class SmbTransport(
} }
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath)) onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
Log.d(TAG, "download ${buildUrl(remotePath)} -> $localPath (${localFile.length()} bytes)") Log.d(TAG, "download ${buildUrl(remotePath)} -> $localPath (${localFile.length()} bytes)")
Result.success(Unit) AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "download failed: $remotePath", e) 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) { withContext(Dispatchers.IO) {
try { try {
val dir = smbFile(remoteDir) val dir = smbFile(remoteDir)
if (!dir.exists() || !dir.isDirectory) { 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 // SmbFile.getName() in jcifs-ng 2.1.x is broken — it concatenates
// parent-dir + filename without separator. Use the URL to extract // parent-dir + filename without separator. Use the URL to extract
@@ -154,66 +159,74 @@ class SmbTransport(
} }
?: emptyList() ?: emptyList()
Log.d(TAG, "listFiles $remoteDir -> ${entries.size} entries: ${entries.joinToString { "${it.name}(${if (it.isDirectory) "d" else "f"},${it.size})" }}") 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) { } catch (e: SmbException) {
if (e.ntStatus == 0xC0000034.toInt()) { 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) 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) { } catch (e: Exception) {
Log.e(TAG, "listFiles failed: $remoteDir", e) 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) { withContext(Dispatchers.IO) {
try { try {
val dir = smbFile(remotePath) val dir = smbFile(remotePath)
if (!dir.exists()) dir.mkdirs() if (!dir.exists()) dir.mkdirs()
Result.success(Unit) AppResult.Success(Unit)
} catch (e: SmbException) { } catch (e: SmbException) {
// STATUS_OBJECT_NAME_COLLISION (0xC0000035): directory already exists — not an error // STATUS_OBJECT_NAME_COLLISION (0xC0000035): directory already exists — not an error
if (e.ntStatus == 0xC0000035.toInt()) { if (e.ntStatus == 0xC0000035.toInt()) {
Result.success(Unit) AppResult.Success(Unit)
} else { } else {
Log.e(TAG, "mkdirs failed: $remotePath${e.message}") Log.e(TAG, "mkdirs failed: $remotePath${e.message}")
Result.failure(Exception("SMB mkdirs failed: ${e.message}", e)) err(AppError.Remote("SMB 创建目录失败", "mkdirs", cause = e))
} }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "mkdirs failed: $remotePath${e.message}") Log.e(TAG, "mkdirs failed: $remotePath${e.message}")
Result.failure(Exception("SMB mkdirs failed: ${e.message}", e)) 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) { withContext(Dispatchers.IO) {
try { try {
val file = smbFile(remotePath) val file = smbFile(remotePath)
if (file.exists()) file.delete() if (file.exists()) file.delete()
Result.success(Unit) AppResult.Success(Unit)
} catch (e: SmbException) { } catch (e: SmbException) {
// STATUS_OBJECT_NAME_NOT_FOUND (0xC0000034): file already gone — not an error // STATUS_OBJECT_NAME_NOT_FOUND (0xC0000034): file already gone — not an error
if (e.ntStatus == 0xC0000034.toInt()) { if (e.ntStatus == 0xC0000034.toInt()) {
Result.success(Unit) AppResult.Success(Unit)
} else { } else {
Log.w(TAG, "delete failed: $remotePath${e.message}") 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) { } catch (e: Exception) {
Log.w(TAG, "delete failed: $remotePath${e.message}") 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) { withContext(Dispatchers.IO) {
try { try {
Result.success(smbFile(remotePath).exists()) AppResult.Success(smbFile(remotePath).exists())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(Exception("SMB exists check failed: ${e.message}", e)) err(AppError.Remote("SMB 检查失败", "exists", cause = e))
} }
} }
} }

View File

@@ -6,6 +6,7 @@ import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
import com.thegrizzlylabs.sardineandroid.impl.SardineException import com.thegrizzlylabs.sardineandroid.impl.SardineException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
@@ -31,16 +32,14 @@ class WebdavTransport(
return "$baseUrl/$cleanPath" 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) { withContext(Dispatchers.IO) {
try { try {
val url = buildUrl(remotePath) val url = buildUrl(remotePath)
val file = File(localPath) val file = File(localPath)
val fileSize = file.length() val fileSize = file.length()
if (fileSize > 50 * 1024 * 1024L) { if (fileSize > 50 * 1024 * 1024L) {
return@withContext Result.failure( return@withContext err(AppError.Remote("WebDAV 上传: 文件过大 (${fileSize / 1024 / 1024}MB), 上限 50MB", "upload"))
Exception("WebDAV upload: file too large (${fileSize / 1024 / 1024}MB), max 50MB")
)
} }
Log.d(TAG, "upload $localPath -> $url ($fileSize bytes)") Log.d(TAG, "upload $localPath -> $url ($fileSize bytes)")
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath)) onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
@@ -61,14 +60,16 @@ class WebdavTransport(
} }
sardine.put(url, data, "application/octet-stream") sardine.put(url, data, "application/octet-stream")
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath)) onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
Result.success(Unit) AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "upload failed: $remotePath", e) 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) { withContext(Dispatchers.IO) {
try { try {
val url = buildUrl(remotePath) val url = buildUrl(remotePath)
@@ -91,13 +92,15 @@ class WebdavTransport(
} }
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath)) onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
Log.d(TAG, "download $url -> $localPath (${localFile.length()} bytes)") Log.d(TAG, "download $url -> $localPath (${localFile.length()} bytes)")
Result.success(Unit) AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "download failed: $remotePath", e) 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) { withContext(Dispatchers.IO) {
try { try {
val url = buildUrl(remoteDir) val url = buildUrl(remoteDir)
@@ -116,7 +119,9 @@ class WebdavTransport(
isDirectory = it.isDirectory isDirectory = it.isDirectory
) } ) }
Log.d(TAG, "listFiles $remoteDir -> ${entries.size} entries") Log.d(TAG, "listFiles $remoteDir -> ${entries.size} entries")
Result.success(entries) AppResult.Success(entries)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
// Only treat 404 as empty for non-root paths; the caller (listRemoteRecursive) // Only treat 404 as empty for non-root paths; the caller (listRemoteRecursive)
// handles the distinction. We propagate the error so the caller can decide. // handles the distinction. We propagate the error so the caller can decide.
@@ -124,14 +129,14 @@ class WebdavTransport(
if (is404) { if (is404) {
// Return a failure with a distinguishable marker so callers can check // Return a failure with a distinguishable marker so callers can check
Log.d(TAG, "listFiles $remoteDir -> 404 (not found)") 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) 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) { withContext(Dispatchers.IO) {
try { try {
val parts = remotePath.trimStart('/').split("/") val parts = remotePath.trimStart('/').split("/")
@@ -139,34 +144,40 @@ class WebdavTransport(
for (part in parts) { for (part in parts) {
current = if (current.isEmpty()) part else "$current/$part" current = if (current.isEmpty()) part else "$current/$part"
try { sardine.createDirectory(buildUrl(current)) } 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) { } catch (e: Exception) {
Log.w(TAG, "mkdirs failed: $remotePath${e.message}") 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) { withContext(Dispatchers.IO) {
try { try {
val url = buildUrl(remotePath) val url = buildUrl(remotePath)
sardine.delete(url) sardine.delete(url)
Result.success(Unit) AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "delete failed (ignoring): $remotePath${e.message}") 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) { withContext(Dispatchers.IO) {
try { try {
val result = sardine.exists(buildUrl(remotePath)) val result = sardine.exists(buildUrl(remotePath))
Result.success(result) AppResult.Success(result)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(Exception("WebDAV exists check failed: ${e.message}", e)) err(AppError.Remote("WebDAV 检查失败", "exists", cause = e))
} }
} }
} }

View File

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

View File

@@ -18,6 +18,7 @@ import com.example.androidbackupgui.backup.BackupService
import com.example.androidbackupgui.backup.ResticBinary import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.ResticWrapper import com.example.androidbackupgui.backup.ResticWrapper
import com.example.androidbackupgui.backup.WifiManager import com.example.androidbackupgui.backup.WifiManager
import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.backup.RemoteTransport import com.example.androidbackupgui.backup.RemoteTransport
import com.example.androidbackupgui.databinding.FragmentBackupBinding import com.example.androidbackupgui.databinding.FragmentBackupBinding
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -69,7 +70,7 @@ class BackupFragment : Fragment() {
applySortFilter() applySortFilter()
} }
binding.selectAllButton.setOnClickListener { binding.selectAllButton.setOnClickListener {
selectedApps.addAll(apps.map { it.packageName }) selectedApps.addAll(apps.map { it.packageName.value })
applySortFilter() applySortFilter()
} }
binding.deselectAllButton.setOnClickListener { binding.deselectAllButton.setOnClickListener {
@@ -118,7 +119,7 @@ class BackupFragment : Fragment() {
val system = AppScanner.scanSystem(ctx, config, userId = selectedUserId) val system = AppScanner.scanSystem(ctx, config, userId = selectedUserId)
apps = if (showSystemApps) thirdParty + system else thirdParty apps = if (showSystemApps) thirdParty + system else thirdParty
selectedApps.clear() selectedApps.clear()
selectedApps.addAll(apps.map { it.packageName }) selectedApps.addAll(apps.map { it.packageName.value })
binding.statusText.text = "共找到 ${apps.size} 个应用,全部已选中" binding.statusText.text = "共找到 ${apps.size} 个应用,全部已选中"
binding.backupButton.isEnabled = apps.isNotEmpty() binding.backupButton.isEnabled = apps.isNotEmpty()
@@ -148,8 +149,17 @@ class BackupFragment : Fragment() {
} }
private fun startBackup() { private fun startBackup() {
val toBackup = apps.filter { it.packageName in selectedApps } val toBackup = apps.filter { it.packageName.value in selectedApps }
if (toBackup.isEmpty()) return if (toBackup.isEmpty()) return
// Check restic local repo availability before doing any work
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank() &&
config.resticBackend == "local" && !File(config.resticRepo, "config").exists()
) {
binding.statusText.text = "restic 本地仓库未初始化,请先在设置中初始化"
return
}
setRunning(true) setRunning(true)
binding.backupButton.isEnabled = false binding.backupButton.isEnabled = false
binding.scanButton.isEnabled = false binding.scanButton.isEnabled = false
@@ -167,7 +177,6 @@ class BackupFragment : Fragment() {
val outputDir = File(config.outputPath.ifEmpty { val outputDir = File(config.outputPath.ifEmpty {
requireContext().filesDir.absolutePath requireContext().filesDir.absolutePath
}) })
WifiManager.backup(outputDir)
val result = BackupOperation.backupApps( val result = BackupOperation.backupApps(
context = requireContext(), context = requireContext(),
apps = toBackup, apps = toBackup,
@@ -175,13 +184,15 @@ class BackupFragment : Fragment() {
outputDir = outputDir, outputDir = outputDir,
userId = selectedUserId.toString(), userId = selectedUserId.toString(),
onProgress = { progress -> onProgress = { progress ->
val label = toBackup.find { it.packageName == progress.packageName }?.label val label = toBackup.find { it.packageName.value == progress.packageName }?.label
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
binding.statusText.text = updateStatus("[${progress.current}/${progress.total}] $name: ${progress.message}")
"[${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 // If restic is enabled, snapshot to repository
var resticSummary: ResticWrapper.BackupSummary? = null var resticSummary: ResticWrapper.BackupSummary? = null
var resticError: String? = null var resticError: String? = null
@@ -194,11 +205,11 @@ class BackupFragment : Fragment() {
if (config.resticBackend == "local") { if (config.resticBackend == "local") {
if (!File(config.resticRepo, "config").exists()) { if (!File(config.resticRepo, "config").exists()) {
binding.statusText.text = "restic 本地仓库未初始化,请先在设置中初始化" updateStatus("restic 本地仓库未初始化,请先在设置中初始化")
return@launch return@launch
} }
} }
binding.statusText.text = "正在写入 restic 去重仓库…" updateStatus("正在写入 restic 去重仓库…")
val resticResult = ResticWrapper.backup( val resticResult = ResticWrapper.backup(
repoPath = config.resticRepo, repoPath = config.resticRepo,
password = config.resticPassword, password = config.resticPassword,

View File

@@ -1,6 +1,7 @@
package com.example.androidbackupgui.ui package com.example.androidbackupgui.ui
import android.view.View import android.view.View
import android.util.TypedValue
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.CheckBox import android.widget.CheckBox
import android.widget.LinearLayout import android.widget.LinearLayout
@@ -28,12 +29,13 @@ class PackageListAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val ctx = parent.context val ctx = parent.context
val res = ctx.resources
val card = MaterialCardView(ctx).apply { val card = MaterialCardView(ctx).apply {
layoutParams = ViewGroup.MarginLayoutParams( layoutParams = ViewGroup.MarginLayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT ViewGroup.LayoutParams.WRAP_CONTENT
).apply { setMargins(0, 0, 0, 8) } ).apply { setMargins(0, 0, 0, res.getDimensionPixelSize(R.dimen.card_margin_bottom)) }
radius = 12f radius = res.getDimension(R.dimen.card_radius)
cardElevation = 0f cardElevation = 0f
strokeWidth = 0 strokeWidth = 0
setCardBackgroundColor( setCardBackgroundColor(
@@ -42,13 +44,13 @@ class PackageListAdapter(
} }
val layout = LinearLayout(ctx).apply { val layout = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL 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 cb = CheckBox(ctx).apply { id = R.id.checkbox }
val tv = TextView(ctx).apply { val tv = TextView(ctx).apply {
id = R.id.appName id = R.id.appName
setPadding(16, 0, 0, 0) setPadding(res.getDimensionPixelSize(R.dimen.card_padding_horizontal), 0, 0, 0)
textSize = 15f setTextSize(TypedValue.COMPLEX_UNIT_PX, res.getDimension(R.dimen.list_item_text_size))
setTextColor( setTextColor(
MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, 0) MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, 0)
) )
@@ -62,12 +64,12 @@ class PackageListAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val app = apps[position] val app = apps[position]
// Prefer app name (label), fall back to package name // Prefer app name (label), fall back to package name
holder.textView.text = app.label.ifEmpty { app.packageName } holder.textView.text = app.label.ifEmpty { app.packageName.value }
// Avoid re-triggering listener during bind // Avoid re-triggering listener during bind
holder.checkbox.setOnCheckedChangeListener(null) holder.checkbox.setOnCheckedChangeListener(null)
holder.checkbox.isChecked = app.packageName in selected holder.checkbox.isChecked = app.packageName.value in selected
holder.checkbox.setOnCheckedChangeListener { _, checked -> holder.checkbox.setOnCheckedChangeListener { _, checked ->
onToggle(app.packageName, checked) onToggle(app.packageName.value, checked)
} }
} }

View File

@@ -11,6 +11,7 @@ import android.app.AlertDialog
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.example.androidbackupgui.backup.AppInfo import com.example.androidbackupgui.backup.AppInfo
import com.example.androidbackupgui.backup.PackageName
import com.example.androidbackupgui.backup.AppScanner import com.example.androidbackupgui.backup.AppScanner
import com.example.androidbackupgui.backup.BackupConfig import com.example.androidbackupgui.backup.BackupConfig
import com.example.androidbackupgui.backup.RestoreOperation import com.example.androidbackupgui.backup.RestoreOperation
@@ -37,6 +38,7 @@ class RestoreFragment : Fragment() {
private var selectedPackages = mutableSetOf<String>() private var selectedPackages = mutableSetOf<String>()
private var resticConfig: BackupConfig? = null private var resticConfig: BackupConfig? = null
private var selectedSnapshot: ResticWrapper.ResticSnapshot? = null private var selectedSnapshot: ResticWrapper.ResticSnapshot? = null
private var resticConfigFingerprint: String? = null
private var selectedUserId: Int = 0 private var selectedUserId: Int = 0
private var userList: List<Pair<Int, String>> = listOf(0 to "Owner") private var userList: List<Pair<Int, String>> = listOf(0 to "Owner")
@@ -97,7 +99,28 @@ class RestoreFragment : Fragment() {
// Re-read config so changes from ConfigFragment take effect immediately // Re-read config so changes from ConfigFragment take effect immediately
val configFile = File(requireContext().filesDir, "backup_settings.conf") val configFile = File(requireContext().filesDir, "backup_settings.conf")
val config = BackupConfig.fromFile(configFile) 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 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" &&
ResticWrapper.backendDomain == config.resticBackendDomain
) {
binding.selectResticButton.visibility = View.VISIBLE
} else {
val binaryPath = ResticBinary.prepare(requireContext()) val binaryPath = ResticBinary.prepare(requireContext())
if (binaryPath != null && resticConfig != null) { if (binaryPath != null && resticConfig != null) {
ResticWrapper.binaryPath = binaryPath ResticWrapper.binaryPath = binaryPath
@@ -106,6 +129,7 @@ class RestoreFragment : Fragment() {
binding.selectResticButton.visibility = View.VISIBLE binding.selectResticButton.visibility = View.VISIBLE
} }
} }
}
private fun selectBackupDir() { private fun selectBackupDir() {
val defaultDir = File(requireContext().filesDir.absolutePath) val defaultDir = File(requireContext().filesDir.absolutePath)
@@ -143,7 +167,7 @@ class RestoreFragment : Fragment() {
binding.statusText.text = "${packages.size} 个备份应用" binding.statusText.text = "${packages.size} 个备份应用"
binding.restoreButton.isEnabled = packages.isNotEmpty() 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() setupAppList()
} }
@@ -161,21 +185,21 @@ class RestoreFragment : Fragment() {
backendPass = config.resticBackendPass, backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare, backendShare = config.resticBackendShare,
onSyncProgress = { p -> onSyncProgress = { p ->
binding.statusText.text = "同步中: ${p.current}/${p.total} [${p.currentFile}]" updateStatus("同步中: ${p.current}/${p.total} [${p.currentFile}]")
}, },
onByteSyncProgress = { bp -> onByteSyncProgress = { bp ->
binding.statusText.text = "下载中: ${bp.bytesTransferred / 1024 / 1024} MB / ${bp.totalBytes / 1024 / 1024} MB" updateStatus("下载中: ${bp.bytesTransferred / 1024 / 1024} MB / ${bp.totalBytes / 1024 / 1024} MB")
} }
) )
if (snapshotsResult.isFailure) { if (snapshotsResult.isFailure) {
binding.statusText.text = "读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}" updateStatus("读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}")
setRunning(false) setRunning(false)
return@launch return@launch
} }
val snapshots = snapshotsResult.getOrThrow() val snapshots = snapshotsResult.getOrThrow()
if (snapshots.isEmpty()) { if (snapshots.isEmpty()) {
binding.statusText.text = "没有可用的 restic 快照" updateStatus("没有可用的 restic 快照")
setRunning(false) setRunning(false)
return@launch return@launch
} }
@@ -185,7 +209,7 @@ class RestoreFragment : Fragment() {
snapshots.first() snapshots.first()
} else { } else {
pickSnapshot(snapshots) ?: run { pickSnapshot(snapshots) ?: run {
binding.statusText.text = "已取消选择" updateStatus("已取消选择")
setRunning(false) setRunning(false)
return@launch return@launch
} }
@@ -195,7 +219,7 @@ class RestoreFragment : Fragment() {
backupDir = null backupDir = null
selectedSnapshot = chosenSnapshot selectedSnapshot = chosenSnapshot
val backupPath = selectedSnapshot!!.paths.firstOrNull() ?: run { val backupPath = selectedSnapshot!!.paths.firstOrNull() ?: run {
binding.statusText.text = "快照中找不到备份路径" updateStatus("快照中找不到备份路径")
setRunning(false) setRunning(false)
return@launch return@launch
} }
@@ -209,7 +233,7 @@ class RestoreFragment : Fragment() {
} }
if (packages.isEmpty()) { if (packages.isEmpty()) {
binding.statusText.text = "无法从快照读取应用列表" updateStatus("无法从快照读取应用列表")
setRunning(false) setRunning(false)
return@launch return@launch
} }
@@ -220,9 +244,9 @@ class RestoreFragment : Fragment() {
selectedPackages.addAll(packages) selectedPackages.addAll(packages)
// Resolve app labels for display // Resolve app labels for display
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = it) }) appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = PackageName(it)) })
binding.statusText.text = "restic 快照共 ${packages.size} 个应用,点击恢复开始" updateStatus("restic 快照共 ${packages.size} 个应用,点击恢复开始")
binding.restoreButton.isEnabled = true binding.restoreButton.isEnabled = true
setRunning(false) setRunning(false)
setupAppList() setupAppList()
@@ -385,6 +409,10 @@ class RestoreFragment : Fragment() {
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
} }
private suspend fun updateStatus(text: String) {
binding.statusText.text = text
}
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null

View File

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

View File

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

View File

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

View File

@@ -46,6 +46,8 @@
<item name="colorSurfaceContainerHigh">@color/surfaceContainerHigh</item> <item name="colorSurfaceContainerHigh">@color/surfaceContainerHigh</item>
<item name="colorSurfaceContainerHighest">@color/surfaceContainerHighest</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 --> <!-- Status bar — dark theme -->
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item>

View File

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

View File

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

View File

@@ -47,6 +47,8 @@
<item name="colorSurfaceContainerHigh">@color/surfaceContainerHigh</item> <item name="colorSurfaceContainerHigh">@color/surfaceContainerHigh</item>
<item name="colorSurfaceContainerHighest">@color/surfaceContainerHighest</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 --> <!-- Status bar -->
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item>

View File

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

View File

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

1
kmboxnet Submodule

Submodule kmboxnet added at 9b62283c62