diff --git a/app/build.gradle b/app/build.gradle index c2ae5d5..a366c1e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,23 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlinx-serialization' +apply plugin: 'org.jetbrains.kotlinx.kover' + +kover { + reports { + filters { + excludes { + classes( + // Generated/auto classes + "*.databinding.*", + "*.BuildConfig", + "*.R", + "*.R\$*" + ) + } + } + } +} android { namespace "com.example.androidbackupgui" @@ -38,6 +55,12 @@ android { } } } + testOptions { + unitTests.all { + + useJUnitPlatform() + } + } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 @@ -72,4 +95,9 @@ dependencies { // root shell via libsu (Magisk/KernelSU/APatch) 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" } diff --git a/app/src/main/java/com/example/androidbackupgui/backup/AppError.kt b/app/src/main/java/com/example/androidbackupgui/backup/AppError.kt new file mode 100644 index 0000000..3e41ddb --- /dev/null +++ b/app/src/main/java/com/example/androidbackupgui/backup/AppError.kt @@ -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> { + * 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 { + data class Success(val data: T) : AppResult() + data class Failure(val error: AppError) : AppResult() + + /** 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 fold( + crossinline onSuccess: (T) -> R, + crossinline onFailure: (AppError) -> R, + ): R = when (this) { + is Success -> onSuccess(data) + is Failure -> onFailure(error) + } + + inline fun map(crossinline transform: (T) -> R): AppResult = 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 = when (this) { + is Success -> this + is Failure -> Failure(transform(error)) + } +} + +/** + * Create a failed [AppResult] wrapping the given [AppError]. + */ +internal fun err(error: AppError): AppResult = AppResult.Failure(error) diff --git a/app/src/main/java/com/example/androidbackupgui/backup/AppScanner.kt b/app/src/main/java/com/example/androidbackupgui/backup/AppScanner.kt index 7d3f3c9..a236085 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/AppScanner.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/AppScanner.kt @@ -19,7 +19,7 @@ data class DataSizes( @Serializable data class AppInfo( - val packageName: String, + val packageName: PackageName, var label: String = "", val isSystem: Boolean = false, val apkPaths: List = emptyList(), @@ -27,7 +27,7 @@ data class AppInfo( val isRunning: Boolean = false, val backupSize: Long = 0, // estimated from last backup // Enhanced fields (multi-user, keystore, icon) - val userId: Int = 0, + val userId: UserId = UserId(0), val hasKeystore: Boolean = false, val iconPath: String? = null, val dataSizes: DataSizes = DataSizes(), @@ -44,11 +44,10 @@ object AppScanner { .filter { it.startsWith("package:") } .map { it.removePrefix("package:").trim() } .filter { it.isNotEmpty() } - .map { AppInfo(packageName = it, userId = userId) } + .map { AppInfo(packageName = PackageName(it), userId = UserId(userId)) } resolveLabels(context, packages) } - /** Scan all system packages. */ suspend fun scanSystem(context: Context, config: BackupConfig, userId: Int = 0): List = withContext(Dispatchers.IO) { val result = RootShell.exec("pm list packages -s --user $userId") if (!result.isSuccess) return@withContext emptyList() @@ -67,7 +66,7 @@ object AppScanner { .filter { pkg -> if (config.blacklistMode == 1) pkg !in blacklist else true } - .map { AppInfo(packageName = it, isSystem = true, userId = userId) } + .map { AppInfo(packageName = PackageName(it), isSystem = true, userId = UserId(userId)) } resolveLabels(context, packages) } @@ -82,10 +81,10 @@ object AppScanner { val pm = context.packageManager for (app in packages) { app.label = try { - val ai = pm.getApplicationInfo(app.packageName, 0) + val ai = pm.getApplicationInfo(app.packageName.value, 0) pm.getApplicationLabel(ai).toString() } catch (_: PackageManager.NameNotFoundException) { - app.packageName + app.packageName.value } } return packages @@ -127,7 +126,7 @@ object AppScanner { /** Check if an app has keystore entries (critical — keystore keys can be lost on backup). */ suspend fun hasKeystore(packageName: String): Boolean = withContext(Dispatchers.IO) { // Resolve the app's UID first - val uidResult = RootShell.exec("dumpsys package '$packageName' | grep 'userId=' | head -1") + val uidResult = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep 'userId=' | head -1") val uid = uidResult.output .substringAfter("userId=", "") .substringBefore(" ") @@ -156,11 +155,11 @@ object AppScanner { suspend fun extractIcon(packageName: String, destDir: java.io.File, userId: Int = 0): String? = withContext(Dispatchers.IO) { // Try snapshot cache first val snapshotDir = "/data/system_ce/$userId/snapshots/$packageName" - val snapshotResult = RootShell.exec("ls '$snapshotDir/' 2>/dev/null | head -1") + val snapshotResult = RootShell.exec("ls '${snapshotDir.shellEscape()}/' 2>/dev/null | head -1") if (snapshotResult.isSuccess && snapshotResult.output.isNotBlank()) { val iconName = snapshotResult.output.trim() val iconFile = java.io.File(destDir, "app_icon.png") - val copyResult = RootShell.exec("cp '${snapshotDir}/${iconName.shellEscape()}' '${iconFile.absolutePath.shellEscape()}' 2>/dev/null") + val copyResult = RootShell.exec("cp '${snapshotDir.shellEscape()}/${iconName.shellEscape()}' '${iconFile.absolutePath.shellEscape()}' 2>/dev/null") if (copyResult.isSuccess && iconFile.exists()) { return@withContext iconFile.absolutePath } diff --git a/app/src/main/java/com/example/androidbackupgui/backup/BackupConfig.kt b/app/src/main/java/com/example/androidbackupgui/backup/BackupConfig.kt index 6aff04f..e958efd 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/BackupConfig.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/BackupConfig.kt @@ -6,36 +6,38 @@ import kotlinx.serialization.Serializable /** * Mirrors backup_settings.conf from backup_script. * All keys correspond 1:1 with the original shell config. + * + * This is an immutable data class. Use [copy] to create modified instances. */ @Serializable data class BackupConfig( // Operation mode - var lo: Int = 0, // 0=volume key, 1=volume force, 2=keyboard - var backgroundExecution: Int = 0, // 0=foreground, 1=background - var setDisplayPowerMode: Int = 0, // 1=keep screen on during backup - var shellLang: String = "", // ""=auto, "1"=zh-CN, "0"=zh-TW + val lo: Int = 0, // 0=volume key, 1=volume force, 2=keyboard + val backgroundExecution: Int = 0, // 0=foreground, 1=background + val setDisplayPowerMode: Int = 0, // 1=keep screen on during backup + val shellLang: String = "", // ""=auto, "1"=zh-CN, "0"=zh-TW // Paths - var outputPath: String = "", // Custom output dir - var listLocation: String = "", // Custom appList.txt location + val outputPath: String = "", // Custom output dir + val listLocation: String = "", // Custom appList.txt location // Update - var update: Int = 1, // 1=auto update - var cdn: Int = 1, // CDN node + val update: Int = 1, // 1=auto update + val cdn: Int = 1, // CDN node // Filters - var mountPoint: String = "rannki|0000-1", - var user: String = "", + val mountPoint: String = "rannki|0000-1", + val user: String = "", // Backup mode - var backupMode: Int = 1, // 1=data+apk, 0=apk only - var backupUserData: Int = 1, - var backupObbData: Int = 1, - var backupMedia: Int = 0, - var backgroundAppsIgnore: Int = 0, + val backupMode: Int = 1, // 1=data+apk, 0=apk only + val backupUserData: Int = 1, + val backupObbData: Int = 1, + val backupMedia: Int = 0, + val backgroundAppsIgnore: Int = 0, // Custom paths - var customPath: List = listOf( + val customPath: List = listOf( "/storage/emulated/0/Pictures/", "/storage/emulated/0/Download/", "/storage/emulated/0/Music", @@ -44,38 +46,37 @@ data class BackupConfig( ), // Blacklist - var blacklistMode: Int = 0, // 1=full ignore, 0=apk only - var blacklist: List = emptyList(), + val blacklistMode: Int = 0, // 1=full ignore, 0=apk only + val blacklist: List = emptyList(), // Whitelists - var whitelist: List = emptyList(), - var system: List = emptyList(), + val whitelist: List = emptyList(), + val system: List = emptyList(), // Compression - var compressionMethod: String = "zstd", // zstd or tar + val compressionMethod: String = "zstd", // zstd or tar // Terminal colors - var rgbA: Int = 226, - var rgbB: Int = 123, - var rgbC: Int = 177, + val rgbA: Int = 226, + val rgbB: Int = 123, + val rgbC: Int = 177, - var backupWifi: Int = 1, + val backupWifi: Int = 1, // Restic deduplicated backup with rclone backend - var resticEnabled: Int = 0, - var resticRepo: String = "", - var resticPassword: String = "", - var resticBackend: String = "local", // local / webdav / smb - var resticBackendUrl: String = "", - var resticBackendUser: String = "", - var resticBackendPass: String = "", - var resticBackendShare: String = "", // SMB share name - var resticBackendDomain: String = "" // SMB domain (optional, for NTLM) + val resticEnabled: Int = 0, + val resticRepo: String = "", + val resticPassword: String = "", + val resticBackend: String = "local", // local / webdav / smb + val resticBackendUrl: String = "", + val resticBackendUser: String = "", + val resticBackendPass: String = "", + val resticBackendShare: String = "", // SMB share name + val resticBackendDomain: String = "" // SMB domain (optional, for NTLM) ) { companion object { fun fromFile(file: File): BackupConfig { - val config = BackupConfig() - if (!file.exists()) return config + if (!file.exists()) return BackupConfig() val props = mutableMapOf() file.forEachLine { line -> @@ -97,41 +98,42 @@ data class BackupConfig( .map { it.replace("%20", " ") } } - config.lo = int("Lo") - config.backgroundExecution = int("background_execution") - config.setDisplayPowerMode = int("setDisplayPowerMode") - config.shellLang = str("Shell_LANG") - config.outputPath = str("Output_path") - config.listLocation = str("list_location") - config.update = int("update", default = 1) - config.cdn = int("cdn", default = 1) - config.mountPoint = str("mount_point") - config.user = str("user") - config.backupMode = int("Backup_Mode", default = 1) - config.backupUserData = int("Backup_user_data", default = 1) - config.backupObbData = int("Backup_obb_data", default = 1) - config.backupMedia = int("backup_media") - config.backgroundAppsIgnore = int("Background_apps_ignore") - config.customPath = lines("Custom_path") - config.blacklistMode = int("blacklist_mode") - config.blacklist = lines("blacklist") - config.whitelist = lines("whitelist") - config.system = lines("system") - config.compressionMethod = str("Compression_method").ifEmpty { "zstd" } - config.rgbA = int("rgb_a").let { if (it == 0) 226 else it } - config.rgbB = int("rgb_b").let { if (it == 0) 123 else it } - config.rgbC = int("rgb_c").let { if (it == 0) 177 else it } - config.backupWifi = int("backup_wifi", default = 1) - config.resticEnabled = int("restic_enabled") - config.resticRepo = str("restic_repo") - config.resticPassword = str("restic_password") - config.resticBackend = str("restic_backend").ifEmpty { "local" } - config.resticBackendUrl = str("restic_backend_url") - config.resticBackendUser = str("restic_backend_user") - config.resticBackendPass = str("restic_backend_pass") - config.resticBackendShare = str("restic_backend_share") - config.resticBackendDomain = str("restic_backend_domain") - return config + return BackupConfig( + lo = int("Lo"), + backgroundExecution = int("background_execution"), + setDisplayPowerMode = int("setDisplayPowerMode"), + shellLang = str("Shell_LANG"), + outputPath = str("Output_path"), + listLocation = str("list_location"), + update = int("update", default = 1), + cdn = int("cdn", default = 1), + mountPoint = str("mount_point"), + user = str("user"), + backupMode = int("Backup_Mode", default = 1), + backupUserData = int("Backup_user_data", default = 1), + backupObbData = int("Backup_obb_data", default = 1), + backupMedia = int("backup_media"), + backgroundAppsIgnore = int("Background_apps_ignore"), + customPath = lines("Custom_path"), + blacklistMode = int("blacklist_mode"), + blacklist = lines("blacklist"), + whitelist = lines("whitelist"), + system = lines("system"), + compressionMethod = str("Compression_method").ifEmpty { "zstd" }, + rgbA = int("rgb_a").let { if (it == 0) 226 else it }, + rgbB = int("rgb_b").let { if (it == 0) 123 else it }, + rgbC = int("rgb_c").let { if (it == 0) 177 else it }, + backupWifi = int("backup_wifi", default = 1), + resticEnabled = int("restic_enabled"), + resticRepo = str("restic_repo"), + resticPassword = str("restic_password"), + resticBackend = str("restic_backend").ifEmpty { "local" }, + resticBackendUrl = str("restic_backend_url"), + resticBackendUser = str("restic_backend_user"), + resticBackendPass = str("restic_backend_pass"), + resticBackendShare = str("restic_backend_share"), + resticBackendDomain = str("restic_backend_domain"), + ) } fun toFile(config: BackupConfig, file: File) { diff --git a/app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt b/app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt index 492d992..89bff7d 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt @@ -3,15 +3,15 @@ package com.example.androidbackupgui.backup import com.example.androidbackupgui.root.RootShell import android.util.Log import com.example.androidbackupgui.root.shellEscape +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import java.io.File import org.json.JSONObject -import kotlin.coroutines.coroutineContext import kotlinx.serialization.Serializable import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import java.util.concurrent.atomic.AtomicInteger @@ -68,7 +68,7 @@ object BackupOperation { // Write app list val appListFile = File(backupRoot, "appList.txt") - appListFile.writeText(apps.joinToString("\n") { it.packageName }) + appListFile.writeText(apps.joinToString("\n") { it.packageName.value }) // Write metadata JSON val metaFile = File(backupRoot, "app_details.json") @@ -80,17 +80,17 @@ object BackupOperation { val skippedAtomic = AtomicInteger(0) coroutineScope { - apps.forEachIndexed { index, app -> - launch { - if (!coroutineContext.isActive) return@launch + apps.mapIndexed { index, app -> + async { semaphore.withPermit { - val appDir = File(backupRoot, app.packageName) + ensureActive() + val appDir = File(backupRoot, app.packageName.value) appDir.mkdirs() - emit(BackupProgress(index + 1, apps.size, app.packageName, "apk", "正在备份 APK…")) + emit(BackupProgress(index + 1, apps.size, app.packageName.value, "apk", "正在备份 APK…")) // 1. Backup APK - val paths = AppScanner.getApkPaths(app.packageName) + val paths = AppScanner.getApkPaths(app.packageName.value) val apkOk = if (paths.isNotEmpty()) { paths.withIndex().all { (i, apkPath) -> val destName = if (paths.size > 1) "${app.packageName}_split_$i.apk" else "${app.packageName}.apk" @@ -100,57 +100,57 @@ object BackupOperation { if (!apkOk) { 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 } // 1.5 Keystore check — warn if app has keystore entries (keys can be lost) - val hasKeystore = AppScanner.hasKeystore(app.packageName) + val hasKeystore = AppScanner.hasKeystore(app.packageName.value) if (hasKeystore) { - emit(BackupProgress(index + 1, apps.size, app.packageName, "data", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失")) + emit(BackupProgress(index + 1, apps.size, app.packageName.value, "data", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失")) } // 2. Backup user data (if configured) if (config.backupMode == 1 && config.backupUserData == 1) { - emit(BackupProgress(index + 1, apps.size, app.packageName, "data", "正在备份数据…")) - if (!backupUserData(context, app.packageName, appDir, userId, config.compressionMethod)) { + emit(BackupProgress(index + 1, apps.size, app.packageName.value, "data", "正在备份数据…")) + if (!backupUserData(context, app.packageName.value, appDir, userId, config.compressionMethod)) { failAtomic.incrementAndGet() - emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "数据备份失败")) + emit(BackupProgress(index + 1, apps.size, app.packageName.value, "done", "数据备份失败")) return@withPermit } } // 3. Backup OBB (if configured and exists) if (config.backupMode == 1 && config.backupObbData == 1) { - val hasObb = AppScanner.hasObbData(app.packageName) + val hasObb = AppScanner.hasObbData(app.packageName.value) if (hasObb) { - emit(BackupProgress(index + 1, apps.size, app.packageName, "obb", "正在备份 OBB…")) - if (!backupObb(app.packageName, appDir, config.compressionMethod)) { + emit(BackupProgress(index + 1, apps.size, app.packageName.value, "obb", "正在备份 OBB…")) + if (!backupObb(app.packageName.value, appDir, config.compressionMethod)) { failAtomic.incrementAndGet() - emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "OBB 备份失败")) + emit(BackupProgress(index + 1, apps.size, app.packageName.value, "done", "OBB 备份失败")) return@withPermit } } } // 4. Backup SSAID - emit(BackupProgress(index + 1, apps.size, app.packageName, "ssaid", "正在备份 SSAID…")) - backupSsaid(app.packageName, appDir, userId) + emit(BackupProgress(index + 1, apps.size, app.packageName.value, "ssaid", "正在备份 SSAID…")) + backupSsaid(app.packageName.value, appDir, userId) // 4.5 Backup app icon - val iconPath = AppScanner.extractIcon(app.packageName, appDir, app.userId) + val iconPath = AppScanner.extractIcon(app.packageName.value, appDir, app.userId.value) if (iconPath != null) { Log.d(TAG, "backupApps: saved icon for ${app.packageName} -> $iconPath") } // 5. Backup runtime permissions - backupPermissions(app.packageName, appDir) + backupPermissions(app.packageName.value, appDir) successAtomic.incrementAndGet() - emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "完成")) + emit(BackupProgress(index + 1, apps.size, app.packageName.value, "done", "完成")) } } - } + }.awaitAll() } val elapsed = System.currentTimeMillis() - startTime @@ -209,7 +209,7 @@ object BackupOperation { var archiveCreated = false var result: RootShell.ShellResult? = null - val dirs = dataPaths.filter { RootShell.exec("test -d $it").isSuccess }.toMutableList() + val dirs = dataPaths.filter { RootShell.exec("test -d '${it.shellEscape()}'").isSuccess }.toMutableList() if (dirs.isNotEmpty()) { Log.d(TAG, "backupUserData: $packageName test -d found dirs=$dirs") result = runTar(dirs, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes) @@ -227,9 +227,9 @@ object BackupOperation { Log.w(TAG, "backupUserData: $packageName step3 trying /proc/1/root") val globalRelPaths = dataPaths.map { it.removePrefix("/") } val globalCmd = if (isZstd) { - "cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(" ")} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'" + "cd /proc/1/root && set -o pipefail; $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'" } else { - "cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ")} 2>/dev/null" + "cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null" } result = RootShell.exec(globalCmd) archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0) @@ -275,12 +275,12 @@ object BackupOperation { excludes: List = emptyList() ): RootShell.ShellResult { val excludeArgs = if (excludes.isNotEmpty()) { - excludes.joinToString(" ") { "--exclude='$it'" } + excludes.joinToString(" ") { "--exclude='${it.shellEscape()}'" } } else "" return if (isZstd) { - RootShell.exec("$tarCmd -cf - $excludeArgs ${dirs.joinToString(" ")} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'") + RootShell.exec("set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'") } else { - RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ")} 2>/dev/null") + RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null") } } private suspend fun backupObb(packageName: String, appDir: File, compression: String): Boolean { @@ -290,7 +290,7 @@ object BackupOperation { // Exclude cache and backup temp files from OBB archive val obbExcludes = "--exclude='cache' --exclude='Backup_*'" val result = when (compression) { - "zstd" -> RootShell.exec("tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'") + "zstd" -> RootShell.exec("set -o pipefail; tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'") else -> RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null") } if (!result.isSuccess) { @@ -343,7 +343,7 @@ object BackupOperation { val entry = JSONObject() entry.put("label", app.label) entry.put("isSystem", app.isSystem) - root.put(app.packageName, entry) + root.put(app.packageName.value, entry) } return root.toString(2) } diff --git a/app/src/main/java/com/example/androidbackupgui/backup/BinaryResolver.kt b/app/src/main/java/com/example/androidbackupgui/backup/BinaryResolver.kt index 02fc488..8b0cd38 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/BinaryResolver.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/BinaryResolver.kt @@ -12,25 +12,28 @@ import java.io.File object BinaryResolver { private const val TAG = "BinaryResolver" - private val cacheTar = ResolveCache() - private val cacheZstd = ResolveCache() + private var tarPath: String? = null + private var zstdPath: String? = null - private class ResolveCache { - var initialized = false - var path: String? = null + fun tarPath(context: Context): String? = cacheOrResolve(context, "libtar_bin.so", "tar_bin", ::tarPath) { tarPath = it } + fun zstdPath(context: Context): String? = cacheOrResolve(context, "libzstd_bin.so", "zstd_bin", ::zstdPath) { zstdPath = it } + + private fun cacheOrResolve( + context: Context, libName: String, destName: String, + cache: () -> String?, setCache: (String?) -> Unit + ): String? { + val cached = cache() + if (cached != null) return cached + val resolved = resolve(context, libName, destName) + setCache(resolved) + return resolved } - fun tarPath(context: Context): String? = resolve(context, "libtar_bin.so", "tar_bin", cacheTar) - fun zstdPath(context: Context): String? = resolve(context, "libzstd_bin.so", "zstd_bin", cacheZstd) - - private fun resolve(context: Context, libName: String, destName: String, cache: ResolveCache): String? { - if (cache.initialized) return cache.path + private fun resolve(context: Context, libName: String, destName: String): String? { val nativeLibDir = context.applicationInfo.nativeLibraryDir val source = File(nativeLibDir, libName) if (!source.isFile) { Log.e(TAG, "$libName NOT FOUND at ${source.absolutePath}") - cache.initialized = true - cache.path = null return null } val dest = File(context.filesDir, "bin/$destName") @@ -40,10 +43,7 @@ object BinaryResolver { source.inputStream().use { src -> dest.outputStream().use { out -> src.copyTo(out) } } dest.setExecutable(true) } - val result = dest.absolutePath - Log.i(TAG, "ready: $libName -> $result (${dest.length()} bytes) canExec=${dest.canExecute()}") - cache.path = result - cache.initialized = true - return result + Log.i(TAG, "ready: $libName -> ${dest.absolutePath} (${dest.length()} bytes) canExec=${dest.canExecute()}") + return dest.absolutePath } } diff --git a/app/src/main/java/com/example/androidbackupgui/backup/DomainTypes.kt b/app/src/main/java/com/example/androidbackupgui/backup/DomainTypes.kt new file mode 100644 index 0000000..355f922 --- /dev/null +++ b/app/src/main/java/com/example/androidbackupgui/backup/DomainTypes.kt @@ -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) + } +} diff --git a/app/src/main/java/com/example/androidbackupgui/backup/RemoteSyncManager.kt b/app/src/main/java/com/example/androidbackupgui/backup/RemoteSyncManager.kt index 6ba1294..eb97600 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/RemoteSyncManager.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/RemoteSyncManager.kt @@ -6,6 +6,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.CONFLATED +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch import java.io.File /** @@ -19,6 +23,11 @@ import java.io.File */ class RemoteSyncManager { + private sealed interface SyncEvent { + data class Phase(val progress: RemoteTransport.TransferProgress) : SyncEvent + data class Bytes(val progress: RemoteTransport.ByteProgress) : SyncEvent + } + private val TAG = "ResticWrapper" /** Local temp directory used as restic repo for SMB/WebDAV backends. */ @@ -42,9 +51,9 @@ class RemoteSyncManager { private fun ensureTransport( backend: String, url: String, user: String, pass: String, share: String, repoPath: String ): RemoteTransport? = synchronized(transportLock) { - val key = "$backend|$url|$user|$pass|$share|$backendDomain|$repoPath" + val key = "$backend|$url|$user|${pass.hashCode()}|$share|$backendDomain|$repoPath" 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 // syncFromRemote downloads fresh data from the new backend if (transportConfigKey.isNotEmpty() && tempRepoDir.isNotEmpty()) { @@ -122,71 +131,91 @@ class RemoteSyncManager { needsUpload: Boolean, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, - action: suspend () -> Result - ): Result { + action: suspend () -> AppResult + ): AppResult { if (backend != "smb" && backend != "webdav") return action() return repoSyncMutex.withLock { - var shouldCleanup = false - try { - val t = ensureTransport(backend, backendUrl, backendUser, backendPass, backendShare, repoPath) - ?: return@withLock Result.failure(Exception("Failed to create transport for backend: $backend")) - - val localDir = File(tempRepoDir) - - val emitProgress: suspend (RemoteTransport.TransferProgress) -> Unit = { p -> - withContext(Dispatchers.Main) { onProgress(p) } + coroutineScope { + var shouldCleanup = false + var lastByteEmitMs = 0L + val progressChannel = Channel(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 + } + } + } + } } - // Write ops always download to avoid overwriting remote changes. - // Read-only ops skip download if local repo is already present. - val actualDownload = needsDownload && (needsUpload || !isLocalRepoPopulated()) - if (actualDownload) { - Log.i(TAG, "syncFromRemote start: $repoPath -> $tempRepoDir") - val syncResult = RemoteTransport.syncFromRemote(t, localDir, repoPath, emitProgress, onByteProgress) - if (syncResult.isFailure) { + try { + val t = ensureTransport(backend, backendUrl, backendUser, backendPass, backendShare, repoPath) + ?: return@coroutineScope err(AppError.Remote("Failed to create transport for backend: $backend", "connecting")) + + val localDir = File(tempRepoDir) + + val emitProgress: suspend (RemoteTransport.TransferProgress) -> Unit = { p -> + progressChannel.send(SyncEvent.Phase(p)) + } + val emitByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit = { p -> + progressChannel.send(SyncEvent.Bytes(p)) + } + + // Write ops always download to avoid overwriting remote changes. + // Read-only ops skip download if local repo is already present. + val actualDownload = needsDownload && (needsUpload || !isLocalRepoPopulated()) + if (actualDownload) { + Log.i(TAG, "syncFromRemote start: $repoPath -> $tempRepoDir") + val syncResult = RemoteTransport.syncFromRemote(t, localDir, repoPath, emitProgress, emitByteProgress) + if (syncResult.isFailure) { + shouldCleanup = true + Log.e(TAG, "syncFromRemote FAILED: ${syncResult.exceptionOrNull()?.message}") + return@coroutineScope err(AppError.Remote("syncFromRemote failed: ${syncResult.exceptionOrNull()?.message}", "download")) + } + Log.i(TAG, "syncFromRemote complete") + } else if (needsDownload) { + Log.i(TAG, "syncFromRemote skipped: local repo already populated") + } + + val result = action() + + if (needsUpload && result.isSuccess) { + Log.i(TAG, "syncToRemote start: $tempRepoDir -> $repoPath") + val uploadResult = RemoteTransport.syncToRemote(t, localDir, repoPath, emitProgress, emitByteProgress) + if (uploadResult.isFailure) { + shouldCleanup = false // PRESERVE local repo — snapshot would be lost + Log.e(TAG, "syncToRemote FAILED: ${uploadResult.exceptionOrNull()?.message} — local repo preserved for retry") + return@coroutineScope err(AppError.Remote("syncToRemote failed: ${uploadResult.exceptionOrNull()?.message}", "upload")) + } + Log.i(TAG, "syncToRemote complete") + shouldCleanup = true + } else if (result.isFailure) { shouldCleanup = true - Log.e(TAG, "syncFromRemote FAILED: ${syncResult.exceptionOrNull()?.message}") - return@withLock Result.failure( - Exception("syncFromRemote failed: ${syncResult.exceptionOrNull()?.message}") - ) } - Log.i(TAG, "syncFromRemote complete") - } else if (needsDownload) { - Log.i(TAG, "syncFromRemote skipped: local repo already populated") - } - val result = action() - - if (needsUpload && result.isSuccess) { - Log.i(TAG, "syncToRemote start: $tempRepoDir -> $repoPath") - val uploadResult = RemoteTransport.syncToRemote(t, localDir, repoPath, emitProgress, onByteProgress) - if (uploadResult.isFailure) { - shouldCleanup = false // PRESERVE local repo — snapshot would be lost - Log.e(TAG, "syncToRemote FAILED: ${uploadResult.exceptionOrNull()?.message} — local repo preserved for retry") - return@withLock Result.failure( - Exception("syncToRemote failed: ${uploadResult.exceptionOrNull()?.message}") - ) + result + } catch (e: CancellationException) { + shouldCleanup = true + throw e + } catch (e: Exception) { + shouldCleanup = true + err(AppError.Remote(e.message ?: "Unknown error", "sync", cause = e)) + } finally { + progressChannel.close() + progressJob.join() + if (shouldCleanup) { + Log.i(TAG, "withRemoteSync: cleaning up temp dirs") + cleanupTempDirs() + } else { + Log.d(TAG, "withRemoteSync: keeping local repo for subsequent ops") } - Log.i(TAG, "syncToRemote complete") - shouldCleanup = true - } else if (result.isFailure) { - shouldCleanup = true - } - - result - } catch (e: CancellationException) { - shouldCleanup = true - throw e - } catch (e: Exception) { - shouldCleanup = true - Result.failure(e) - } finally { - if (shouldCleanup) { - Log.i(TAG, "withRemoteSync: cleaning up temp dirs") - cleanupTempDirs() - } else { - Log.d(TAG, "withRemoteSync: keeping local repo for subsequent ops") } } } diff --git a/app/src/main/java/com/example/androidbackupgui/backup/RemoteTransport.kt b/app/src/main/java/com/example/androidbackupgui/backup/RemoteTransport.kt index 612ba48..f7ae223 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/RemoteTransport.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/RemoteTransport.kt @@ -4,11 +4,10 @@ import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +import kotlinx.coroutines.CancellationException import java.io.File import kotlinx.serialization.Serializable -/** Thrown by transports when a remote directory genuinely does not exist (HTTP 404). */ -class FileNotFoundException(path: String) : Exception("Directory not found: $path") /** * Unified abstraction for remote file transport (SMB / WebDAV). @@ -38,17 +37,17 @@ interface RemoteTransport { val currentFile: String ) - suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {}): Result - suspend fun download(remotePath: String, localPath: String, onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {}): Result + suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {}): AppResult + suspend fun download(remotePath: String, localPath: String, onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {}): AppResult /** List entries in a remote directory (files and subdirectories). */ - suspend fun listFiles(remoteDir: String): Result> + suspend fun listFiles(remoteDir: String): AppResult> /** Create a directory and any missing parents on the remote. */ - suspend fun mkdirs(remotePath: String): Result + suspend fun mkdirs(remotePath: String): AppResult - suspend fun delete(remotePath: String): Result - suspend fun exists(remotePath: String): Result + suspend fun delete(remotePath: String): AppResult + suspend fun exists(remotePath: String): AppResult companion object { private const val TAG = "RemoteTransport" @@ -79,9 +78,9 @@ interface RemoteTransport { */ private suspend fun withRetry( tag: String, - block: suspend () -> Result - ): Result { - var lastError: Result? = null + block: suspend () -> AppResult + ): AppResult { + var lastError: AppResult? = null for (attempt in 0..MAX_RETRIES) { if (attempt > 0) { val waitMs = 1000L * (1 shl (attempt - 1)) // 1s, 2s, 4s @@ -97,7 +96,7 @@ interface RemoteTransport { } 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( @@ -133,7 +132,7 @@ interface RemoteTransport { remoteDir: String, onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {} - ): Result = withContext(Dispatchers.IO) { + ): AppResult = withContext(Dispatchers.IO) { try { localDir.mkdirs() 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. if (remoteFiles == null) { 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)) val remoteByPath = remoteFiles.associateBy { it.path } @@ -174,9 +173,7 @@ interface RemoteTransport { // If any download failed, abort before deleting local files — // deleting would destroy valid data for an incomplete sync. if (errors.isNotEmpty()) { - return@withContext Result.failure( - Exception("syncFromRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}") - ) + return@withContext err(AppError.Remote("syncFromRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}", "sync")) } // Delete local files not on remote (e.g. after prune on another client) @@ -210,7 +207,7 @@ interface RemoteTransport { remoteDir: String, onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {} - ): Result = withContext(Dispatchers.IO) { + ): AppResult = withContext(Dispatchers.IO) { try { val localFiles = walkLocalFiles(localDir) onProgress(TransferProgress("list", 0, localFiles.size)) @@ -261,9 +258,7 @@ interface RemoteTransport { // If any upload failed, abort before deleting remote files — // deleting during failed sync could lose the only copy on remote. if (errors.isNotEmpty()) { - return@withContext Result.failure( - Exception("syncToRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}") - ) + return@withContext err(AppError.Remote("syncToRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}", "sync")) } // Delete remote files no longer present locally @@ -275,9 +270,11 @@ interface RemoteTransport { transport.delete("$remoteDir/$relPath") } onProgress(TransferProgress("complete", uploaded, syncTotal, "已传输: $uploaded 跳过: $uploadSkipped")) - Result.success(Unit) + AppResult.Success(Unit) + } catch (e: CancellationException) { + throw e } 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) } if (listResult.isFailure) { - val err = listResult.exceptionOrNull() + val err = listResult.errorOrNull() // 404 on a subdirectory: directory doesn't exist, skip it silently. // 404 on the root directory: fatal — the remote repo path may be wrong. - if (err is FileNotFoundException) { + if (err?.isFileNotFound() == true) { if (subDir.isEmpty()) { Log.e(TAG, "listRemoteRecursive: root dir '$fullDir' returned 404 — repo may not exist or is rate-limited") return null @@ -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 diff --git a/app/src/main/java/com/example/androidbackupgui/backup/ResticBackup.kt b/app/src/main/java/com/example/androidbackupgui/backup/ResticBackup.kt index 343d1bf..d3d8fe6 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/ResticBackup.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/ResticBackup.kt @@ -6,6 +6,9 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlin.coroutines.coroutineContext +import com.example.androidbackupgui.backup.AppError +import com.example.androidbackupgui.backup.AppResult +import com.example.androidbackupgui.backup.err /** Shared Json instance configured for restic's snake_case output via @SerialName. */ private val resticJson = Json { ignoreUnknownKeys = true } @@ -21,7 +24,7 @@ class ResticBackup( private val envResolver: ResticEnvResolver, private val syncManager: RemoteSyncManager ) { - private val TAG = "ResticWrapper" + private val TAG = "ResticBackup" // ── Backup ───────────────────────────────────────── @@ -39,7 +42,7 @@ class ResticBackup( onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {} - ): Result = withContext(Dispatchers.IO) { + ): AppResult = withContext(Dispatchers.IO) { val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } } syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, needsDownload = true, needsUpload = true, @@ -61,7 +64,7 @@ class ResticBackup( } 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) @@ -71,16 +74,16 @@ class ResticBackup( // ── Internal helpers ─────────────────────────────── /** Parse the JSON summary from the end of restic backup output. */ - private fun parseBackupSummary(stdout: String): Result { + private fun parseBackupSummary(stdout: String): AppResult { val lines = stdout.lines() for (i in lines.indices.reversed()) { val line = lines[i].trim() if (!line.startsWith("{")) continue try { val summary = resticJson.decodeFromString(line) - if (summary.messageType == "summary" && summary.snapshotId.isNotEmpty()) return Result.success(summary) + if (summary.messageType == "summary" && summary.snapshotId.isNotEmpty()) return AppResult.Success(summary) } catch (_: Exception) { /* keep looking */ } } - return Result.failure(Exception("No summary found in restic output")) + return err(AppError.Parse("restic 备份输出未找到摘要信息", "stdout=" + stdout.length)) } } diff --git a/app/src/main/java/com/example/androidbackupgui/backup/ResticBinary.kt b/app/src/main/java/com/example/androidbackupgui/backup/ResticBinary.kt index d6ef640..82f31da 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/ResticBinary.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/ResticBinary.kt @@ -41,5 +41,5 @@ object ResticBinary { return dir.absolutePath } - fun isReady(): Boolean = false // call prepare() instead + fun isReady(): Boolean = cachedBinaryPath != null } diff --git a/app/src/main/java/com/example/androidbackupgui/backup/ResticCommandRunner.kt b/app/src/main/java/com/example/androidbackupgui/backup/ResticCommandRunner.kt index 0d7927d..34fb3cf 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/ResticCommandRunner.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/ResticCommandRunner.kt @@ -3,6 +3,7 @@ package com.example.androidbackupgui.backup import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive +import com.example.androidbackupgui.backup.AppError import kotlinx.coroutines.withContext import java.io.BufferedReader import java.io.File @@ -28,17 +29,18 @@ class ResticCommandRunner { ) /** Build the full command list to run restic. */ - fun buildCommandArgs(args: List): List { - val cmd = listOf(binaryPath) + args - Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args → cmd=$cmd") - return cmd - } + fun buildCommandArgs(args: List): List = + (listOf(binaryPath) + args).also { cmd -> + Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args -> cmd=$cmd") + } /** Run restic (non-streaming). */ fun runRestic(env: Map, args: List): CommandResult { val cmdArgs = buildCommandArgs(args) Log.i(TAG, "runRestic cmd=${cmdArgs.joinToString(" ")}") Log.d(TAG, "runRestic REPOSITORY=${env["RESTIC_REPOSITORY"]}") + // NOTE: Do NOT log RESTIC_PASSWORD or any value derived from it. + // RESTIC_REPOSITORY is safe to log (does not contain secrets). env["TMPDIR"]?.let { File(it).mkdirs() } return try { val pb = ProcessBuilder(cmdArgs) @@ -46,25 +48,15 @@ class ResticCommandRunner { pb.redirectErrorStream(false) val process = pb.start() - val stderrText = StringBuilder() - val stderrThread = Thread({ - try { - process.errorStream.bufferedReader().use { reader -> - var line: String? - while (reader.readLine().also { line = it } != null) { - Log.d(TAG, "restic stderr: $line") - stderrText.appendLine(line) - } - } - } catch (_: Exception) {} - }, "restic-stderr").apply { isDaemon = true; start() } - val stdout = process.inputStream.bufferedReader().use(BufferedReader::readText) val exitCode = process.waitFor() - stderrThread.join(5000) + val stderrBytes = process.errorStream.readAllBytes() + val stderrText = stderrBytes.decodeToString() Log.i(TAG, "runRestic exitCode=$exitCode stdout_len=${stdout.length}") - if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText}") - CommandResult(stdout.trim(), stderrText.toString().trim(), exitCode) + if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText.trim()}") + CommandResult(stdout.trim(), stderrText.trim(), exitCode) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (e: Exception) { Log.e(TAG, "runRestic exception", e) CommandResult("", e.message ?: "Unknown error", -1) @@ -136,7 +128,9 @@ class ResticCommandRunner { Log.i(TAG, "runResticStreaming exitCode=$exitCode stdout_len=${stdoutText.length}") if (stderrText.isNotEmpty()) Log.w(TAG, "runResticStreaming stderr: ${stderrText}") - CommandResult(stdoutText.toString().trim(), stderrText.toString().trim(), exitCode) + CommandResult(stdoutText.toString().trim(), stderrText.trim(), exitCode) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (e: Exception) { Log.e(TAG, "runResticStreaming exception", e) try { process?.destroy() } catch (_: Exception) {} diff --git a/app/src/main/java/com/example/androidbackupgui/backup/ResticMaintenance.kt b/app/src/main/java/com/example/androidbackupgui/backup/ResticMaintenance.kt index 168513b..8230ddb 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/ResticMaintenance.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/ResticMaintenance.kt @@ -2,6 +2,9 @@ package com.example.androidbackupgui.backup import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import com.example.androidbackupgui.backup.AppError +import com.example.androidbackupgui.backup.AppResult +import com.example.androidbackupgui.backup.err /** * Repository maintenance operations: prune, check, stats. @@ -28,7 +31,7 @@ class ResticMaintenance( backendShare: String = "", onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, - ): Result = + ): AppResult = withContext(Dispatchers.IO) { syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, needsDownload = true, needsUpload = true, @@ -37,8 +40,8 @@ class ResticMaintenance( ) { val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) val result = runner.runRestic(env, "prune") - if (result.exitCode == 0) Result.success(result.stdout) - else Result.failure(Exception("restic prune failed: ${result.stderr}")) + if (result.exitCode == 0) AppResult.Success(result.stdout) + else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr)) } } @@ -54,7 +57,7 @@ class ResticMaintenance( backendShare: String = "", onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, - ): Result = + ): AppResult = withContext(Dispatchers.IO) { syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, needsDownload = true, needsUpload = false, @@ -63,8 +66,8 @@ class ResticMaintenance( ) { val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) val result = runner.runRestic(env, "check") - if (result.exitCode == 0) Result.success(result.stdout) - else Result.failure(Exception("restic check failed: ${result.stderr}")) + if (result.exitCode == 0) AppResult.Success(result.stdout) + else err(AppError.Restic("restic check 失败", result.exitCode, result.stderr)) } } @@ -80,7 +83,7 @@ class ResticMaintenance( backendShare: String = "", onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, - ): Result = + ): AppResult = withContext(Dispatchers.IO) { syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, needsDownload = true, needsUpload = false, @@ -89,8 +92,8 @@ class ResticMaintenance( ) { val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) val result = runner.runRestic(env, "stats") - if (result.exitCode == 0) Result.success(result.stdout) - else Result.failure(Exception("restic stats failed: ${result.stderr}")) + if (result.exitCode == 0) AppResult.Success(result.stdout) + else err(AppError.Restic("restic stats 失败", result.exitCode, result.stderr)) } } } diff --git a/app/src/main/java/com/example/androidbackupgui/backup/ResticRepoInit.kt b/app/src/main/java/com/example/androidbackupgui/backup/ResticRepoInit.kt index b166fde..28736d1 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/ResticRepoInit.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/ResticRepoInit.kt @@ -3,6 +3,9 @@ package com.example.androidbackupgui.backup import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import com.example.androidbackupgui.backup.AppError +import com.example.androidbackupgui.backup.AppResult +import com.example.androidbackupgui.backup.err /** * Repository lifecycle operations: init and repo URL construction. @@ -29,7 +32,7 @@ class ResticRepoInit( backendShare: String = "", onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, - ): Result = + ): AppResult = withContext(Dispatchers.IO) { syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, needsDownload = true, needsUpload = true, @@ -40,7 +43,7 @@ class ResticRepoInit( val result = runner.runRestic(env, "init") // exitCode 0 = brand new repo created, needs upload 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 if (result.exitCode == 1) { @@ -48,14 +51,14 @@ class ResticRepoInit( if (verify.exitCode == 0) { // Repo is healthy — already initialized with matching password Log.i(TAG, "init: repo already initialized and verified") - return@withRemoteSync Result.success(Unit) + return@withRemoteSync AppResult.Success(Unit) } // Config exists but repo is corrupted (wrong password, missing keys, etc.) - return@withRemoteSync Result.failure( - Exception("仓库已存在但无法验证: ${verify.stderr.ifEmpty { "密码错误或密钥缺失" }}。请删除远端仓库后重试。") + return@withRemoteSync err( + AppError.Restic("仓库已存在但无法验证", verify.exitCode, verify.stderr) ) } - Result.failure(Exception("restic init failed: ${result.stderr}")) + err(AppError.Restic("restic init 失败", result.exitCode, result.stderr)) } } diff --git a/app/src/main/java/com/example/androidbackupgui/backup/ResticRestore.kt b/app/src/main/java/com/example/androidbackupgui/backup/ResticRestore.kt index 21e6765..f811c87 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/ResticRestore.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/ResticRestore.kt @@ -6,6 +6,9 @@ import kotlinx.coroutines.withContext import java.io.File import kotlinx.serialization.json.Json import kotlin.coroutines.coroutineContext +import com.example.androidbackupgui.backup.AppError +import com.example.androidbackupgui.backup.AppResult +import com.example.androidbackupgui.backup.err /** Shared Json instance configured for restic's snake_case output via @SerialName. */ private val resticJson = Json { ignoreUnknownKeys = true } @@ -38,7 +41,7 @@ class ResticRestore( onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onProgress: suspend (String) -> Unit = {} - ): Result = withContext(Dispatchers.IO) { + ): AppResult = withContext(Dispatchers.IO) { val emit: suspend (String) -> Unit = { s -> withContext(Dispatchers.Main) { onProgress(s) } } syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, needsDownload = true, needsUpload = false, @@ -67,8 +70,8 @@ class ResticRestore( } catch (_: Exception) { emit(line) } } - if (result.exitCode == 0) Result.success(Unit) - else Result.failure(Exception("restic restore failed: ${result.stderr}")) + if (result.exitCode == 0) AppResult.Success(Unit) + else err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr)) } } @@ -86,7 +89,7 @@ class ResticRestore( backendShare: String = "", onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, - ): Result = withContext(Dispatchers.IO) { + ): AppResult = withContext(Dispatchers.IO) { syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, needsDownload = true, needsUpload = false, onProgress = onSyncProgress, @@ -94,8 +97,8 @@ class ResticRestore( ) { val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) val result = runner.runRestic(env, "dump", snapshotId, filePath) - if (result.exitCode == 0) Result.success(result.stdout) - else Result.failure(Exception(result.stderr.ifEmpty { "restic dump failed with exit code ${result.exitCode}" })) + if (result.exitCode == 0) AppResult.Success(result.stdout) + else err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr)) } } } diff --git a/app/src/main/java/com/example/androidbackupgui/backup/ResticSnapshotOps.kt b/app/src/main/java/com/example/androidbackupgui/backup/ResticSnapshotOps.kt index 683aa27..ccb8b5c 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/ResticSnapshotOps.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/ResticSnapshotOps.kt @@ -3,6 +3,9 @@ package com.example.androidbackupgui.backup import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import com.example.androidbackupgui.backup.AppError +import com.example.androidbackupgui.backup.AppResult +import com.example.androidbackupgui.backup.err /** Shared Json instance configured for restic's snake_case output via @SerialName. */ @@ -33,7 +36,7 @@ class ResticSnapshotOps( backendShare: String = "", onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, - ): Result> = withContext(Dispatchers.IO) { + ): AppResult> = withContext(Dispatchers.IO) { syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, needsDownload = true, needsUpload = false, onProgress = onSyncProgress, @@ -46,16 +49,16 @@ class ResticSnapshotOps( val result = runner.runRestic(env, args) 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 { val snapshots = resticJson.decodeFromString>( result.stdout.ifEmpty { "[]" } ) - Result.success(snapshots.sortedByDescending { it.time }) - } catch (e: Exception) { - Result.failure(Exception("Failed to parse snapshot JSON: ${e.message}")) + AppResult.Success(snapshots.sortedByDescending { it.time }) + } catch (e: Exception) { + err(AppError.Parse("解析快照 JSON 失败", e.message ?: "")) } } } @@ -76,7 +79,7 @@ class ResticSnapshotOps( backendShare: String = "", onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, - ): Result = withContext(Dispatchers.IO) { + ): AppResult = withContext(Dispatchers.IO) { syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, needsDownload = true, needsUpload = true, onProgress = onSyncProgress, @@ -93,8 +96,8 @@ class ResticSnapshotOps( val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) val result = runner.runRestic(env, args) - if (result.exitCode == 0) Result.success(result.stdout) - else Result.failure(Exception("restic forget failed: ${result.stderr}")) + if (result.exitCode == 0) AppResult.Success(result.stdout) + else err(AppError.Restic("restic forget 失败", result.exitCode, result.stderr)) } } } diff --git a/app/src/main/java/com/example/androidbackupgui/backup/ResticWrapper.kt b/app/src/main/java/com/example/androidbackupgui/backup/ResticWrapper.kt index 8dea616..07c397f 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/ResticWrapper.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/ResticWrapper.kt @@ -8,6 +8,9 @@ import kotlinx.coroutines.withContext import kotlin.coroutines.coroutineContext import kotlinx.serialization.Serializable import kotlinx.serialization.SerialName +import com.example.androidbackupgui.backup.AppError +import com.example.androidbackupgui.backup.AppResult +import com.example.androidbackupgui.backup.err /** * Wraps the restic CLI binary for backup/restore operations. @@ -93,7 +96,7 @@ object ResticWrapper { backendShare: String = "", onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, - ): Result = repoInit.init( + ): AppResult = repoInit.init( repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, onSyncProgress, onByteSyncProgress ) @@ -132,7 +135,7 @@ object ResticWrapper { onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onProgress: suspend (ResticProgress) -> Unit = {} - ): Result = backupOp.backup( + ): AppResult = backupOp.backup( repoPath, password, paths, tags, hostname, backend, backendUrl, backendUser, backendPass, backendShare, onSyncProgress, onByteSyncProgress, onProgress @@ -154,7 +157,7 @@ object ResticWrapper { onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, onProgress: suspend (String) -> Unit = {} - ): Result = restoreOp.restore( + ): AppResult = restoreOp.restore( repoPath, password, snapshotId, targetPath, include, backend, backendUrl, backendUser, backendPass, backendShare, onSyncProgress, onByteSyncProgress, onProgress @@ -174,7 +177,7 @@ object ResticWrapper { backendShare: String = "", onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, - ): Result = restoreOp.dump( + ): AppResult = restoreOp.dump( repoPath, password, snapshotId, filePath, backend, backendUrl, backendUser, backendPass, backendShare, onSyncProgress, onByteSyncProgress @@ -193,7 +196,7 @@ object ResticWrapper { backendShare: String = "", onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, - ): Result> = snapshotOps.listSnapshots( + ): AppResult> = snapshotOps.listSnapshots( repoPath, password, tag, backend, backendUrl, backendUser, backendPass, backendShare, onSyncProgress, onByteSyncProgress @@ -213,7 +216,7 @@ object ResticWrapper { backendShare: String = "", onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, - ): Result = snapshotOps.forget( + ): AppResult = snapshotOps.forget( repoPath, password, keepDaily, keepWeekly, keepMonthly, dryRun, backend, backendUrl, backendUser, backendPass, backendShare, onSyncProgress, onByteSyncProgress @@ -231,7 +234,7 @@ object ResticWrapper { backendShare: String = "", onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, - ): Result = maintenance.prune( + ): AppResult = maintenance.prune( repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, onSyncProgress, onByteSyncProgress @@ -247,7 +250,7 @@ object ResticWrapper { backendShare: String = "", onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, - ): Result = maintenance.check( + ): AppResult = maintenance.check( repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, onSyncProgress, onByteSyncProgress @@ -263,7 +266,7 @@ object ResticWrapper { backendShare: String = "", onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, - ): Result = maintenance.stats( + ): AppResult = maintenance.stats( repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, onSyncProgress, onByteSyncProgress diff --git a/app/src/main/java/com/example/androidbackupgui/backup/RestoreOperation.kt b/app/src/main/java/com/example/androidbackupgui/backup/RestoreOperation.kt index 98ecf93..18f3158 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/RestoreOperation.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/RestoreOperation.kt @@ -1,6 +1,7 @@ package com.example.androidbackupgui.backup import com.example.androidbackupgui.root.RootShell import com.example.androidbackupgui.root.shellEscape +import android.content.Context import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive @@ -42,6 +43,7 @@ object RestoreOperation { * @param filterPkgs if non-null, only restore packages in this set */ suspend fun restoreApps( + context: Context, backupDir: File, userId: String = "0", filterPkgs: Set? = null, @@ -50,6 +52,11 @@ object RestoreOperation { val emit: suspend (RestoreProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } } val startTime = System.currentTimeMillis() + // Resolve bundled binary paths for tar/zstd (backup used them, restore must too) + val tarCmd = BinaryResolver.tarPath(context) ?: "tar" + val bundledZstd = BinaryResolver.zstdPath(context) + val zstdCmd = bundledZstd ?: "zstd" + // Read app list from backup val appListFile = File(backupDir, "appList.txt") val allPackages = if (appListFile.exists()) { @@ -88,7 +95,7 @@ object RestoreOperation { // 1. Install APK emit(RestoreProgress(index + 1, packages.size, pkg, "install", "正在安装 APK…")) - val installed = installApk(appBackupDir) + val installed = installApk(pkg, appBackupDir) if (!installed) { failAtomic.incrementAndGet() @@ -101,11 +108,11 @@ object RestoreOperation { // 3. Restore data emit(RestoreProgress(index + 1, packages.size, pkg, "data", "正在恢复数据…")) - restoreData(appBackupDir) + restoreData(pkg, userId, appBackupDir, tarCmd, zstdCmd) // 4. Restore OBB emit(RestoreProgress(index + 1, packages.size, pkg, "obb", "正在恢复 OBB…")) - restoreObb(pkg, appBackupDir) + restoreObb(pkg, appBackupDir, tarCmd, zstdCmd) // 5. Restore SSAID emit(RestoreProgress(index + 1, packages.size, pkg, "ssaid", "正在恢复 SSAID…")) @@ -132,7 +139,7 @@ object RestoreOperation { RestoreResult(successCount, failCount, elapsed) } - private suspend fun installApk(appDir: File): Boolean { + private suspend fun installApk(packageName: String, appDir: File): Boolean { // Find APK files val apkFiles = appDir.listFiles() ?.filter { it.name.endsWith(".apk") } @@ -141,33 +148,68 @@ object RestoreOperation { if (apkFiles.isEmpty()) return false - // Build install command for multiple APKs (split APK support) - val apkPaths = apkFiles.joinToString(" ") { "'${it.absolutePath.shellEscape()}'" } + suspend fun doInstall(): Boolean { + // Build install command for multiple APKs (split APK support) + val apkPaths = apkFiles.joinToString(" ") { "'${it.absolutePath.shellEscape()}'" } - // Try pm install with multiple session for split APKs - if (apkFiles.size > 1) { - val result = RootShell.exec("pm install-create -r -t 2>/dev/null") - val sessionId = result.output.lines() - .firstOrNull { it.contains("Success") } - ?.substringAfter("[") - ?.substringBefore("]") + // Try pm install with multiple session for split APKs + if (apkFiles.size > 1) { + val result = RootShell.exec("pm install-create -r -t 2>/dev/null") + val sessionId = result.output.lines() + .firstOrNull { it.contains("Success") } + ?.substringAfter("[") + ?.substringBefore("]") - if (sessionId != null) { - for ((i, apk) in apkFiles.withIndex()) { - val sessionName = if (i == 0) "base.apk" else "split_${i}.apk" - RootShell.exec("pm install-write '${sessionId.shellEscape()}' '$sessionName' '${apk.absolutePath.shellEscape()}'") + if (sessionId != null) { + for ((i, apk) in apkFiles.withIndex()) { + val sessionName = if (i == 0) "base.apk" else "split_${i}.apk" + RootShell.exec("pm install-write '${sessionId.shellEscape()}' '$sessionName' '${apk.absolutePath.shellEscape()}'") + } + val commit = RootShell.exec("pm install-commit '${sessionId.shellEscape()}'") + return commit.isSuccess } - val commit = RootShell.exec("pm install-commit '${sessionId.shellEscape()}'") - return commit.isSuccess } + + // Single APK install + val result = RootShell.exec("pm install -r -t $apkPaths") + return result.isSuccess } - // Single APK install - val result = RootShell.exec("pm install -r -t $apkPaths") - return result.isSuccess + suspend fun isInstalled(): Boolean { + val verifyResult = RootShell.exec("pm list packages '${packageName.shellEscape()}' 2>/dev/null") + return verifyResult.output.contains(packageName) + } + + // First install attempt + val firstOk = doInstall() + if (!firstOk) { + Log.e(TAG, "installApk: $packageName — first install attempt failed") + return false + } + + // Verify installation succeeded + if (isInstalled()) { + Log.i(TAG, "installApk: $packageName installed and verified") + return true + } + + Log.w(TAG, "installApk: $packageName installed but not detected — retrying once") + val retryOk = doInstall() + if (!retryOk) { + Log.e(TAG, "installApk: $packageName — retry install failed") + return false + } + + if (isInstalled()) { + Log.i(TAG, "installApk: $packageName installed and verified (after retry)") + return true + } + + Log.e(TAG, "installApk: $packageName — install reported success but package not found after retry") + return false } - private suspend fun restoreData(appDir: File) { + private suspend fun restoreData(packageName: String, userId: String, appDir: File, tarCmd: String, zstdCmd: String) { val files = appDir.listFiles() if (files.isNullOrEmpty()) { Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}") @@ -178,27 +220,60 @@ object RestoreOperation { Log.w(TAG, "restoreData: no _data.tar in ${appDir.name}, found: ${files.map { it.name }}") return } + + // Build exclusion patterns for cache/temp directories + val dataPaths = listOf("/data/data/$packageName", "/data/user_de/$userId/$packageName") + val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup") + val excludeArgs = dataPaths.flatMap { dataPath -> + excludeFolders.flatMap { folder -> + listOf("--exclude='${dataPath.shellEscape()}/$folder'", "--exclude='${dataPath.shellEscape()}/$folder/*'") + } + }.joinToString(" ") + for (archive in dataFiles) { val archivePath = archive.absolutePath.shellEscape() Log.d(TAG, "restoreData: found archive ${archive.name}") - if (!isArchiveSafe(archive)) { + if (!isArchiveSafe(archive, zstdCmd)) { Log.w(TAG, "restoreData: archive NOT SAFE, skipping: ${archive.name}") continue } - val cmd = when { + + // Build the extract command with exclusion flags + val baseCmd = when { archive.name.endsWith(".zst") -> - "zstd -d -c '$archivePath' | tar -xf - -C / 2>/dev/null" + "set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null" archive.name.endsWith(".gz") -> - "tar -xzf '$archivePath' -C / 2>/dev/null" + "$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null" archive.name.endsWith(".tar") -> - "tar -xf '$archivePath' -C / 2>/dev/null" + "$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null" else -> { Log.w(TAG, "restoreData: unknown archive type ${archive.name}"); continue } } - val result = RootShell.exec(cmd) + + val result = RootShell.exec(baseCmd) if (result.isSuccess) { Log.i(TAG, "restoreData: extracted ${archive.name}") } else { Log.e(TAG, "restoreData: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}") + // Continue to try SELinux fix even if extraction had issues + } + } + + // Restore SELinux context on extracted data directories + for (dataPath in dataPaths) { + // Try to get the existing context (if the path already existed) + val existingContext = SELinuxUtil.getContext(dataPath) + val context = existingContext ?: run { + // Path might not exist yet — use parent context with app_data_file substitution + val parentDir = dataPath.substringBeforeLast("/") + val parentContext = SELinuxUtil.getContext(parentDir) + parentContext?.replace("system_data_file", "app_data_file") + } + + if (context != null) { + Log.d(TAG, "restoreData: restoring SELinux context on $dataPath: $context") + SELinuxUtil.chcon(context, dataPath) + } else { + Log.w(TAG, "restoreData: could not determine SELinux context for $dataPath") } } } @@ -208,13 +283,18 @@ object RestoreOperation { * or symbolic links pointing outside the tree. * Accepts both absolute and relative paths — tar implementations vary. */ - private suspend fun isArchiveSafe(archive: File): Boolean { + private suspend fun isArchiveSafe(archive: File, zstdCmd: String = "zstd"): Boolean { val listCmd = if (archive.name.endsWith(".zst")) { - "zstd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null" + "set -o pipefail; $zstdCmd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null" } else { "tar tf '${archive.absolutePath.shellEscape()}' 2>/dev/null" } - val result = RootShell.exec(listCmd) + var result = RootShell.exec(listCmd) + // Fallback: try without pipefail (some Android shells don't support it) + if (!result.isSuccess && archive.name.endsWith(".zst")) { + val fallbackCmd = "$zstdCmd -d -c '${archive.absolutePath.shellEscape()}' 2>/dev/null | tar tf - 2>/dev/null" + result = RootShell.exec(fallbackCmd) + } if (!result.isSuccess) return false return !result.output.lines().any { line -> val path = line.substringBefore(" -> ") @@ -226,29 +306,39 @@ object RestoreOperation { } } - private suspend fun restoreObb(packageName: String, appDir: File) { + private suspend fun restoreObb(packageName: String, appDir: File, tarCmd: String, zstdCmd: String) { val obbFiles = appDir.listFiles() ?.filter { it.name.contains("_obb.tar") } ?: return + if (obbFiles.isEmpty()) return + + // Build exclusion patterns for OBB cache/temp directories + val obbPath = "/storage/emulated/0/Android/obb/$packageName" + val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup", "Backup_*") + val excludeArgs = excludeFolders.joinToString(" ") { "--exclude='${obbPath.shellEscape()}/$it' --exclude='${obbPath.shellEscape()}/$it/*'" } + for (archive in obbFiles) { - if (!isArchiveSafe(archive)) continue + if (!isArchiveSafe(archive, zstdCmd)) continue val archivePath = archive.absolutePath.shellEscape() when { archive.name.endsWith(".zst") -> { - RootShell.exec("zstd -d -c '$archivePath' | tar -xf - -C / 2>/dev/null") + RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null") } archive.name.endsWith(".gz") -> { - RootShell.exec("tar -xzf '$archivePath' -C / 2>/dev/null") + RootShell.exec("$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null") } archive.name.endsWith(".tar") -> { - RootShell.exec("tar -xf '$archivePath' -C / 2>/dev/null") + RootShell.exec("$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null") } } } - // Fix OBB permissions - RootShell.exec("chown -R 1023:1023 /storage/emulated/0/Android/obb/${packageName.shellEscape()}/ 2>/dev/null") + // Fix OBB permissions: resolve GID from parent directory instead of hardcoding 1023 + val gidResult = RootShell.exec("stat -c %g '${obbPath.shellEscape()}' 2>/dev/null") + val gid = gidResult.output.trim().toIntOrNull() ?: 1023 // fallback to media_rw gid + RootShell.exec("chown -R $gid:$gid '${obbPath.shellEscape()}/' 2>/dev/null") + Log.i(TAG, "restoreObb: set ownership to $gid:$gid on $obbPath") } private suspend fun restoreSsaid(packageName: String, appDir: File, userId: String) { @@ -267,23 +357,60 @@ object RestoreOperation { .trim() .toIntOrNull() - if (uid != null) { - // Use settings put secure to set SSAID (more reliable than XML manipulation) - val result = RootShell.exec("settings put secure ssaid_$uid '$ssaidValue'") - if (result.isSuccess) { - Log.i(TAG, "restoreSsaid: restored SSAID for $packageName (uid=$uid)") - } else { - Log.w(TAG, "restoreSsaid: failed to set SSAID for $packageName: ${result.error}") + if (uid == null) { + Log.w(TAG, "restoreSsaid: could not resolve UID for $packageName") + return + } + + // Try XML-based approach first (more reliable across Android versions) + val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml" + val xmlSuccess = run { + // Check if file exists + val checkResult = RootShell.exec("test -f '$targetFile' && echo 'exists'") + if (!checkResult.output.contains("exists")) { + Log.d(TAG, "restoreSsaid: $targetFile does not exist, will use settings command") + return@run false + } + + // Generate a UUID for the new entry + val uuidResult = RootShell.exec("cat /proc/sys/kernel/random/uuid 2>/dev/null") + val id = uuidResult.output.trim() + if (id.length != 36) { // UUID format check + Log.w(TAG, "restoreSsaid: could not generate UUID (got '$id'), falling back") + return@run false + } + + // Remove existing entry for this package and insert new one before + val manipCmd = buildString { + append("sed -i \"/package.*${packageName.shellEscape()}/d\" '$targetFile' && ") + append("sed -i \"s##\\n#\" '$targetFile'") + } + val result = RootShell.exec(manipCmd) + if (!result.isSuccess) { + Log.w(TAG, "restoreSsaid: XML edit failed: ${result.error}") + return@run false + } + + // Verify the package entry was added by checking if it appears in the file now + val verifyCmd = RootShell.exec("grep -c \"${packageName.shellEscape()}\" '$targetFile' 2>/dev/null") + val entryCount = verifyCmd.output.trim().toIntOrNull() ?: 0 + if (entryCount > 0) { + Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via XML (uid=$uid)") + true + } else { + Log.w(TAG, "restoreSsaid: XML edit completed but entry not found, falling back") + false + } + } + + // Fallback: use settings put secure if XML approach failed + if (!xmlSuccess) { + val result = RootShell.exec("settings put secure ssaid_$uid '${ssaidValue.shellEscape()}'") + if (result.isSuccess) { + Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via settings (uid=$uid)") + } else { + Log.e(TAG, "restoreSsaid: failed to set SSAID for $packageName: ${result.error}") } - } else { - Log.w(TAG, "restoreSsaid: could not resolve UID for $packageName, falling back to XML edit") - // Fallback: edit settings_ssaid.xml directly - val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml" - RootShell.exec( - "grep -v '${packageName.shellEscape()}' '$targetFile' > '$targetFile.tmp' && " + - "sed -i '\$ i ${ssaidValue.shellEscape()}' '$targetFile.tmp' && " + - "mv '$targetFile.tmp' '$targetFile'" - ) } } @@ -291,43 +418,109 @@ object RestoreOperation { val permFile = File(appDir, "permissions.txt") if (!permFile.exists()) return - // dumpsys 输出格式: "android.permission.XXX: granted=true" 或 "permission.XXX: granted=true" - // 各 Android 版本输出有差异,try-catch 兜底避免单权限失败中断全部 - val perms = try { - permFile.readLines() - .filter { it.contains("granted=true") } - .mapNotNull { line -> - line.substringBefore(":") - .trim() - .takeIf { it.isNotEmpty() && it.contains(".") } - } + // Parse permissions from dumpsys output. + // Format: "android.permission.XXX: granted=true" or "android.permission.XXX: granted=false" + val parsedPerms = try { + permFile.readLines().mapNotNull { line -> + val name = line.substringBefore(":").trim().takeIf { it.isNotEmpty() && it.contains(".") } ?: return@mapNotNull null + val granted = line.contains("granted=true") + Pair(name, granted) + } } catch (_: Exception) { emptyList() } + if (parsedPerms.isEmpty()) return + val pkgEsc = packageName.shellEscape() - for (perm in perms) { + + // Reset app ops first (clears any previous modes) + RootShell.exec("appops reset '$pkgEsc' 2>/dev/null") + + val grantedPerms = parsedPerms.filter { it.second }.map { it.first } + val deniedPerms = parsedPerms.filter { !it.second }.map { it.first } + + // Grant runtime permissions that were previously granted + for (perm in grantedPerms) { val result = RootShell.exec("pm grant '$pkgEsc' '${perm.shellEscape()}' 2>&1") if (!result.isSuccess) { - android.util.Log.w("RestoreOperation", "pm grant failed for $packageName: $perm — ${result.output}") + Log.w(TAG, "restorePermissions: pm grant failed for $packageName: $perm — ${result.output}") } } + + // Revoke runtime permissions that were explicitly denied + for (perm in deniedPerms) { + val result = RootShell.exec("pm revoke '$pkgEsc' '${perm.shellEscape()}' 2>&1") + if (!result.isSuccess) { + // Revoking a permission that isn't granted is not an error — just log at debug level + Log.d(TAG, "restorePermissions: pm revoke for $packageName: $perm — ${result.output}") + } + } + + Log.i(TAG, "restorePermissions: ${grantedPerms.size} granted, ${deniedPerms.size} revoked for $packageName") } - private suspend fun fixDataOwnership(packageName: String, userId: String) { + /** Resolve app UID using multiple methods for robustness across Android versions. */ + private suspend fun resolveAppUid(packageName: String): Int? { val pkgEsc = packageName.shellEscape() - val uidEsc = userId.shellEscape() - val uidResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1") - val uid = uidResult.output + // Method 1: pm list packages -U (reliable, consistent output format) + val pmResult = RootShell.exec("pm list packages -U 2>/dev/null | grep '${pkgEsc}$'") + val pmUid = pmResult.output + .substringAfter(" uid:") + .trim() + .toIntOrNull() + if (pmUid != null) return pmUid + + // Method 2: dumpsys package (fallback for older Android) + val dsResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1") + val dsUid = dsResult.output .substringAfter("userId=", "") .substringBefore(" ") .substringBefore(",") .trim() .toIntOrNull() + if (dsUid != null) return dsUid - if (uid != null) { - RootShell.exec("chown -R $uid:$uid /data/data/$pkgEsc/ 2>/dev/null") - RootShell.exec("chown -R $uid:$uid /data/user_de/$uidEsc/$pkgEsc/ 2>/dev/null") - RootShell.exec("restorecon -R /data/data/$pkgEsc/ 2>/dev/null") - RootShell.exec("restorecon -R /data/user_de/$uidEsc/$pkgEsc/ 2>/dev/null") + // Method 3: dumpsys with userId: separator (AOSP variant) + val ds2Result = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId:' | head -1") + val ds2Uid = ds2Result.output + .substringAfter("userId:", "") + .substringBefore(" ") + .trim() + .toIntOrNull() + return ds2Uid + } + + private suspend fun fixDataOwnership(packageName: String, userId: String) { + val pkgEsc = packageName.shellEscape() + val uidEsc = userId.shellEscape() + + val uid = resolveAppUid(packageName) + if (uid == null) { + Log.w(TAG, "fixDataOwnership: could not resolve UID for $packageName — data will be inaccessible") + return + } + + // USER and USER_DE use uid:uid (app's own group) + val dataPaths = listOf( + "/data/data/$pkgEsc", + "/data/user_de/$uidEsc/$pkgEsc" + ) + + for (dataPath in dataPaths) { + RootShell.exec("chown -R $uid:$uid '$dataPath/' 2>/dev/null") + + // Restore SELinux context instead of using restorecon (which applies defaults) + val existingContext = SELinuxUtil.getContext(dataPath) + val context = existingContext ?: run { + val parentDir = dataPath.substringBeforeLast("/") + val parentContext = SELinuxUtil.getContext(parentDir) + parentContext?.replace("system_data_file", "app_data_file") + } + if (context != null) { + SELinuxUtil.chcon(context, dataPath) + Log.d(TAG, "fixDataOwnership: restored SELinux context on $dataPath: $context") + } else { + Log.w(TAG, "fixDataOwnership: could not determine SELinux context for $dataPath") + } } } } diff --git a/app/src/main/java/com/example/androidbackupgui/backup/SELinuxUtil.kt b/app/src/main/java/com/example/androidbackupgui/backup/SELinuxUtil.kt new file mode 100644 index 0000000..568b335 --- /dev/null +++ b/app/src/main/java/com/example/androidbackupgui/backup/SELinuxUtil.kt @@ -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 + } +} diff --git a/app/src/main/java/com/example/androidbackupgui/backup/SmbTransport.kt b/app/src/main/java/com/example/androidbackupgui/backup/SmbTransport.kt index 270e6e6..f9ff3ae 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/SmbTransport.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/SmbTransport.kt @@ -11,6 +11,7 @@ import jcifs.smb.SmbFileInputStream import jcifs.smb.SmbFileOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.coroutines.CancellationException import java.io.File import java.util.Properties @@ -54,7 +55,7 @@ class SmbTransport( private fun smbFile(path: String): SmbFile = SmbFile(buildUrl(path), context) - override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): Result = + override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult = withContext(Dispatchers.IO) { try { val localFile = File(localPath) @@ -83,14 +84,16 @@ class SmbTransport( } onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath)) Log.i(TAG, "upload $localPath -> ${buildUrl(remotePath)} ($fileSize bytes)") - Result.success(Unit) + AppResult.Success(Unit) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e(TAG, "upload failed: ${buildUrl(remotePath)}", e) - Result.failure(Exception("SMB upload failed: ${e.message}", e)) + err(AppError.Remote("SMB 上传失败", "upload", cause = e)) } } - override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): Result = + override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult = withContext(Dispatchers.IO) { try { val localFile = File(localPath) @@ -114,19 +117,21 @@ class SmbTransport( } onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath)) Log.d(TAG, "download ${buildUrl(remotePath)} -> $localPath (${localFile.length()} bytes)") - Result.success(Unit) + AppResult.Success(Unit) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e(TAG, "download failed: $remotePath", e) - Result.failure(Exception("SMB download failed: ${e.message}", e)) + err(AppError.Remote("SMB 下载失败", "download", cause = e)) } } - override suspend fun listFiles(remoteDir: String): Result> = + override suspend fun listFiles(remoteDir: String): AppResult> = withContext(Dispatchers.IO) { try { val dir = smbFile(remoteDir) if (!dir.exists() || !dir.isDirectory) { - return@withContext Result.failure(FileNotFoundException(remoteDir)) + return@withContext err(AppError.Remote("远端路径不存在", "list", isNotFound = true)) } // SmbFile.getName() in jcifs-ng 2.1.x is broken — it concatenates // parent-dir + filename without separator. Use the URL to extract @@ -154,66 +159,74 @@ class SmbTransport( } ?: emptyList() Log.d(TAG, "listFiles $remoteDir -> ${entries.size} entries: ${entries.joinToString { "${it.name}(${if (it.isDirectory) "d" else "f"},${it.size})" }}") - Result.success(entries) + AppResult.Success(entries) } catch (e: SmbException) { if (e.ntStatus == 0xC0000034.toInt()) { - return@withContext Result.failure(FileNotFoundException(remoteDir)) + return@withContext err(AppError.Remote("远端路径不存在", "list", isNotFound = true)) } Log.e(TAG, "listFiles failed: $remoteDir", e) - Result.failure(Exception("SMB list failed: ${e.message}", e)) + err(AppError.Remote("SMB 列表失败", "list", cause = e)) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e(TAG, "listFiles failed: $remoteDir", e) - Result.failure(Exception("SMB list failed: ${e.message}", e)) + err(AppError.Remote("SMB 列表失败", "list", cause = e)) } } - override suspend fun mkdirs(remotePath: String): Result = + override suspend fun mkdirs(remotePath: String): AppResult = withContext(Dispatchers.IO) { try { val dir = smbFile(remotePath) if (!dir.exists()) dir.mkdirs() - Result.success(Unit) + AppResult.Success(Unit) } catch (e: SmbException) { // STATUS_OBJECT_NAME_COLLISION (0xC0000035): directory already exists — not an error if (e.ntStatus == 0xC0000035.toInt()) { - Result.success(Unit) + AppResult.Success(Unit) } else { Log.e(TAG, "mkdirs failed: $remotePath — ${e.message}") - Result.failure(Exception("SMB mkdirs failed: ${e.message}", e)) + err(AppError.Remote("SMB 创建目录失败", "mkdirs", cause = e)) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e(TAG, "mkdirs failed: $remotePath — ${e.message}") - Result.failure(Exception("SMB mkdirs failed: ${e.message}", e)) + err(AppError.Remote("SMB 创建目录失败", "mkdirs", cause = e)) } } - override suspend fun delete(remotePath: String): Result = + override suspend fun delete(remotePath: String): AppResult = withContext(Dispatchers.IO) { try { val file = smbFile(remotePath) if (file.exists()) file.delete() - Result.success(Unit) + AppResult.Success(Unit) } catch (e: SmbException) { // STATUS_OBJECT_NAME_NOT_FOUND (0xC0000034): file already gone — not an error if (e.ntStatus == 0xC0000034.toInt()) { - Result.success(Unit) + AppResult.Success(Unit) } else { Log.w(TAG, "delete failed: $remotePath — ${e.message}") - Result.failure(Exception("SMB delete failed: ${e.message}", e)) + err(AppError.Remote("SMB 删除失败", "delete", cause = e)) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.w(TAG, "delete failed: $remotePath — ${e.message}") - Result.failure(Exception("SMB delete failed: ${e.message}", e)) + err(AppError.Remote("SMB 删除失败", "delete", cause = e)) } } - override suspend fun exists(remotePath: String): Result = + override suspend fun exists(remotePath: String): AppResult = withContext(Dispatchers.IO) { try { - Result.success(smbFile(remotePath).exists()) + AppResult.Success(smbFile(remotePath).exists()) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - Result.failure(Exception("SMB exists check failed: ${e.message}", e)) + err(AppError.Remote("SMB 检查失败", "exists", cause = e)) } } } diff --git a/app/src/main/java/com/example/androidbackupgui/backup/WebdavTransport.kt b/app/src/main/java/com/example/androidbackupgui/backup/WebdavTransport.kt index 657f962..02b034a 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/WebdavTransport.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/WebdavTransport.kt @@ -6,6 +6,7 @@ import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine import com.thegrizzlylabs.sardineandroid.impl.SardineException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.coroutines.CancellationException import java.io.ByteArrayOutputStream import java.io.File @@ -31,16 +32,14 @@ class WebdavTransport( return "$baseUrl/$cleanPath" } - override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): Result = + override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult = withContext(Dispatchers.IO) { try { val url = buildUrl(remotePath) val file = File(localPath) val fileSize = file.length() if (fileSize > 50 * 1024 * 1024L) { - return@withContext Result.failure( - Exception("WebDAV upload: file too large (${fileSize / 1024 / 1024}MB), max 50MB") - ) + return@withContext err(AppError.Remote("WebDAV 上传: 文件过大 (${fileSize / 1024 / 1024}MB), 上限 50MB", "upload")) } Log.d(TAG, "upload $localPath -> $url ($fileSize bytes)") onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath)) @@ -61,14 +60,16 @@ class WebdavTransport( } sardine.put(url, data, "application/octet-stream") onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath)) - Result.success(Unit) + AppResult.Success(Unit) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e(TAG, "upload failed: $remotePath", e) - Result.failure(Exception("WebDAV upload failed: ${e.message}", e)) + err(AppError.Remote("WebDAV 上传失败", "upload", cause = e)) } } - override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): Result = + override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult = withContext(Dispatchers.IO) { try { val url = buildUrl(remotePath) @@ -91,13 +92,15 @@ class WebdavTransport( } onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath)) Log.d(TAG, "download $url -> $localPath (${localFile.length()} bytes)") - Result.success(Unit) + AppResult.Success(Unit) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e(TAG, "download failed: $remotePath", e) - Result.failure(Exception("WebDAV download failed: ${e.message}", e)) + err(AppError.Remote("WebDAV 下载失败", "download", cause = e)) } } - override suspend fun listFiles(remoteDir: String): Result> = + override suspend fun listFiles(remoteDir: String): AppResult> = withContext(Dispatchers.IO) { try { val url = buildUrl(remoteDir) @@ -116,7 +119,9 @@ class WebdavTransport( isDirectory = it.isDirectory ) } Log.d(TAG, "listFiles $remoteDir -> ${entries.size} entries") - Result.success(entries) + AppResult.Success(entries) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { // Only treat 404 as empty for non-root paths; the caller (listRemoteRecursive) // handles the distinction. We propagate the error so the caller can decide. @@ -124,14 +129,14 @@ class WebdavTransport( if (is404) { // Return a failure with a distinguishable marker so callers can check Log.d(TAG, "listFiles $remoteDir -> 404 (not found)") - return@withContext Result.failure(FileNotFoundException(remoteDir)) + return@withContext err(AppError.Remote("远端路径不存在", "list", isNotFound = true)) } Log.e(TAG, "listFiles failed: $remoteDir", e) - Result.failure(Exception("WebDAV list failed: ${e.message}", e)) + err(AppError.Remote("WebDAV 列表失败", "list", cause = e)) } } - override suspend fun mkdirs(remotePath: String): Result = + override suspend fun mkdirs(remotePath: String): AppResult = withContext(Dispatchers.IO) { try { val parts = remotePath.trimStart('/').split("/") @@ -139,34 +144,40 @@ class WebdavTransport( for (part in parts) { current = if (current.isEmpty()) part else "$current/$part" try { sardine.createDirectory(buildUrl(current)) } - catch (_: Exception) { /* already exists or parent missing, continue */ } + catch (_: Exception) { Log.w(TAG, "mkdirs: failed to create $current"); continue } } - Result.success(Unit) + AppResult.Success(Unit) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.w(TAG, "mkdirs failed: $remotePath — ${e.message}") - Result.success(Unit) // best-effort; upload will fail if dir can't be created + AppResult.Success(Unit) // best-effort; upload will fail if dir can't be created } } - override suspend fun delete(remotePath: String): Result = + override suspend fun delete(remotePath: String): AppResult = withContext(Dispatchers.IO) { try { val url = buildUrl(remotePath) sardine.delete(url) - Result.success(Unit) + AppResult.Success(Unit) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.w(TAG, "delete failed (ignoring): $remotePath — ${e.message}") - Result.success(Unit) + err(AppError.Remote("WebDAV 删除失败", "delete", cause = e)) } } - override suspend fun exists(remotePath: String): Result = + override suspend fun exists(remotePath: String): AppResult = withContext(Dispatchers.IO) { try { val result = sardine.exists(buildUrl(remotePath)) - Result.success(result) + AppResult.Success(result) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - Result.failure(Exception("WebDAV exists check failed: ${e.message}", e)) + err(AppError.Remote("WebDAV 检查失败", "exists", cause = e)) } } } diff --git a/app/src/main/java/com/example/androidbackupgui/root/RootShell.kt b/app/src/main/java/com/example/androidbackupgui/root/RootShell.kt index a704eec..5a92dc3 100644 --- a/app/src/main/java/com/example/androidbackupgui/root/RootShell.kt +++ b/app/src/main/java/com/example/androidbackupgui/root/RootShell.kt @@ -3,6 +3,7 @@ package com.example.androidbackupgui.root import android.util.Log import com.topjohnwu.superuser.Shell import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout @@ -67,6 +68,7 @@ object RootShell { suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult = withContext(Dispatchers.IO) { + ensureActive() try { val result = withTimeout(timeoutMs) { Shell.cmd(command).exec() @@ -84,4 +86,17 @@ object RootShell { ShellResult("", e.message ?: "Unknown error", -1) } } + + /** + * 安全执行 root shell 命令,自动 shellEscape 每个参数。 + * @param parts 命令和参数列表,第一个元素是命令本身 + * @param timeoutMs 超时毫秒 + */ + suspend fun execSafe( + parts: List, + timeoutMs: Long = COMMAND_TIMEOUT_MS + ): ShellResult = exec( + command = parts.joinToString(" ") { "'${it.shellEscape()}'" }, + timeoutMs = timeoutMs + ) } diff --git a/app/src/main/java/com/example/androidbackupgui/ui/BackupFragment.kt b/app/src/main/java/com/example/androidbackupgui/ui/BackupFragment.kt index 1cabb7f..c8c147f 100644 --- a/app/src/main/java/com/example/androidbackupgui/ui/BackupFragment.kt +++ b/app/src/main/java/com/example/androidbackupgui/ui/BackupFragment.kt @@ -18,6 +18,7 @@ import com.example.androidbackupgui.backup.BackupService import com.example.androidbackupgui.backup.ResticBinary import com.example.androidbackupgui.backup.ResticWrapper import com.example.androidbackupgui.backup.WifiManager +import com.example.androidbackupgui.backup.AppResult import com.example.androidbackupgui.backup.RemoteTransport import com.example.androidbackupgui.databinding.FragmentBackupBinding import kotlinx.coroutines.Dispatchers @@ -69,7 +70,7 @@ class BackupFragment : Fragment() { applySortFilter() } binding.selectAllButton.setOnClickListener { - selectedApps.addAll(apps.map { it.packageName }) + selectedApps.addAll(apps.map { it.packageName.value }) applySortFilter() } binding.deselectAllButton.setOnClickListener { @@ -118,7 +119,7 @@ class BackupFragment : Fragment() { val system = AppScanner.scanSystem(ctx, config, userId = selectedUserId) apps = if (showSystemApps) thirdParty + system else thirdParty selectedApps.clear() - selectedApps.addAll(apps.map { it.packageName }) + selectedApps.addAll(apps.map { it.packageName.value }) binding.statusText.text = "共找到 ${apps.size} 个应用,全部已选中" binding.backupButton.isEnabled = apps.isNotEmpty() @@ -148,8 +149,17 @@ class BackupFragment : Fragment() { } private fun startBackup() { - val toBackup = apps.filter { it.packageName in selectedApps } + val toBackup = apps.filter { it.packageName.value in selectedApps } if (toBackup.isEmpty()) return + + // Check restic local repo availability before doing any work + if (config.resticEnabled == 1 && config.resticRepo.isNotBlank() && + config.resticBackend == "local" && !File(config.resticRepo, "config").exists() + ) { + binding.statusText.text = "restic 本地仓库未初始化,请先在设置中初始化" + return + } + setRunning(true) binding.backupButton.isEnabled = false binding.scanButton.isEnabled = false @@ -167,7 +177,6 @@ class BackupFragment : Fragment() { val outputDir = File(config.outputPath.ifEmpty { requireContext().filesDir.absolutePath }) - WifiManager.backup(outputDir) val result = BackupOperation.backupApps( context = requireContext(), apps = toBackup, @@ -175,13 +184,15 @@ class BackupFragment : Fragment() { outputDir = outputDir, userId = selectedUserId.toString(), 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 - binding.statusText.text = - "[${progress.current}/${progress.total}] $name: ${progress.message}" + updateStatus("[${progress.current}/${progress.total}] $name: ${progress.message}") } ) + // Store WiFi config inside Backup_* directory so restic/local restore can find it + WifiManager.backup(File(result.outputDir)) + // If restic is enabled, snapshot to repository var resticSummary: ResticWrapper.BackupSummary? = null var resticError: String? = null @@ -194,11 +205,11 @@ class BackupFragment : Fragment() { if (config.resticBackend == "local") { if (!File(config.resticRepo, "config").exists()) { - binding.statusText.text = "restic 本地仓库未初始化,请先在设置中初始化" + updateStatus("restic 本地仓库未初始化,请先在设置中初始化") return@launch } } - binding.statusText.text = "正在写入 restic 去重仓库…" + updateStatus("正在写入 restic 去重仓库…") val resticResult = ResticWrapper.backup( repoPath = config.resticRepo, password = config.resticPassword, diff --git a/app/src/main/java/com/example/androidbackupgui/ui/PackageListAdapter.kt b/app/src/main/java/com/example/androidbackupgui/ui/PackageListAdapter.kt index b372056..4905d41 100644 --- a/app/src/main/java/com/example/androidbackupgui/ui/PackageListAdapter.kt +++ b/app/src/main/java/com/example/androidbackupgui/ui/PackageListAdapter.kt @@ -1,6 +1,7 @@ package com.example.androidbackupgui.ui import android.view.View +import android.util.TypedValue import android.view.ViewGroup import android.widget.CheckBox import android.widget.LinearLayout @@ -28,12 +29,13 @@ class PackageListAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val ctx = parent.context + val res = ctx.resources val card = MaterialCardView(ctx).apply { layoutParams = ViewGroup.MarginLayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { setMargins(0, 0, 0, 8) } - radius = 12f + ).apply { setMargins(0, 0, 0, res.getDimensionPixelSize(R.dimen.card_margin_bottom)) } + radius = res.getDimension(R.dimen.card_radius) cardElevation = 0f strokeWidth = 0 setCardBackgroundColor( @@ -42,13 +44,13 @@ class PackageListAdapter( } val layout = LinearLayout(ctx).apply { orientation = LinearLayout.HORIZONTAL - setPadding(16, 12, 16, 12) + setPadding(res.getDimensionPixelSize(R.dimen.card_padding_horizontal), res.getDimensionPixelSize(R.dimen.card_padding_vertical), res.getDimensionPixelSize(R.dimen.card_padding_horizontal), res.getDimensionPixelSize(R.dimen.card_padding_vertical)) } val cb = CheckBox(ctx).apply { id = R.id.checkbox } val tv = TextView(ctx).apply { id = R.id.appName - setPadding(16, 0, 0, 0) - textSize = 15f + setPadding(res.getDimensionPixelSize(R.dimen.card_padding_horizontal), 0, 0, 0) + setTextSize(TypedValue.COMPLEX_UNIT_PX, res.getDimension(R.dimen.list_item_text_size)) setTextColor( MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, 0) ) @@ -62,12 +64,12 @@ class PackageListAdapter( override fun onBindViewHolder(holder: ViewHolder, position: Int) { val app = apps[position] // 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 holder.checkbox.setOnCheckedChangeListener(null) - holder.checkbox.isChecked = app.packageName in selected + holder.checkbox.isChecked = app.packageName.value in selected holder.checkbox.setOnCheckedChangeListener { _, checked -> - onToggle(app.packageName, checked) + onToggle(app.packageName.value, checked) } } diff --git a/app/src/main/java/com/example/androidbackupgui/ui/RestoreFragment.kt b/app/src/main/java/com/example/androidbackupgui/ui/RestoreFragment.kt index b518596..a10cdc1 100644 --- a/app/src/main/java/com/example/androidbackupgui/ui/RestoreFragment.kt +++ b/app/src/main/java/com/example/androidbackupgui/ui/RestoreFragment.kt @@ -11,6 +11,7 @@ import android.app.AlertDialog import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.example.androidbackupgui.backup.AppInfo +import com.example.androidbackupgui.backup.PackageName import com.example.androidbackupgui.backup.AppScanner import com.example.androidbackupgui.backup.BackupConfig import com.example.androidbackupgui.backup.RestoreOperation @@ -37,6 +38,7 @@ class RestoreFragment : Fragment() { private var selectedPackages = mutableSetOf() private var resticConfig: BackupConfig? = null private var selectedSnapshot: ResticWrapper.ResticSnapshot? = null + private var resticConfigFingerprint: String? = null private var selectedUserId: Int = 0 private var userList: List> = listOf(0 to "Owner") @@ -97,13 +99,35 @@ class RestoreFragment : Fragment() { // Re-read config so changes from ConfigFragment take effect immediately val configFile = File(requireContext().filesDir, "backup_settings.conf") val config = BackupConfig.fromFile(configFile) + + // Detect restic config change — clear stale state if repo/backend changed + val newFingerprint = "${config.resticRepo}|${config.resticBackend}|${config.resticBackendUrl}" + if (resticConfigFingerprint != null && resticConfigFingerprint != newFingerprint) { + selectedSnapshot = null + packages = emptyList() + selectedPackages.clear() + binding.backupDirText.text = "" + binding.restoreButton.isEnabled = false + binding.selectResticButton.visibility = View.GONE + } + resticConfigFingerprint = newFingerprint + resticConfig = if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) config else null - val binaryPath = ResticBinary.prepare(requireContext()) - if (binaryPath != null && resticConfig != null) { - ResticWrapper.binaryPath = binaryPath - ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext()) - ResticWrapper.backendDomain = config.resticBackendDomain + // Skip redundant preparation if binary and backend config are already set + if (resticConfig != null && + ResticWrapper.binaryPath.isNotEmpty() && + ResticWrapper.binaryPath != "restic" && + ResticWrapper.backendDomain == config.resticBackendDomain + ) { binding.selectResticButton.visibility = View.VISIBLE + } else { + val binaryPath = ResticBinary.prepare(requireContext()) + if (binaryPath != null && resticConfig != null) { + ResticWrapper.binaryPath = binaryPath + ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext()) + ResticWrapper.backendDomain = config.resticBackendDomain + binding.selectResticButton.visibility = View.VISIBLE + } } } @@ -143,7 +167,7 @@ class RestoreFragment : Fragment() { binding.statusText.text = "共 ${packages.size} 个备份应用" binding.restoreButton.isEnabled = packages.isNotEmpty() - appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = it) }) + appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = PackageName(it)) }) setupAppList() } @@ -161,21 +185,21 @@ class RestoreFragment : Fragment() { backendPass = config.resticBackendPass, backendShare = config.resticBackendShare, onSyncProgress = { p -> - binding.statusText.text = "同步中: ${p.current}/${p.total} [${p.currentFile}]" + updateStatus("同步中: ${p.current}/${p.total} [${p.currentFile}]") }, 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) { - binding.statusText.text = "读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}" + updateStatus("读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}") setRunning(false) return@launch } val snapshots = snapshotsResult.getOrThrow() if (snapshots.isEmpty()) { - binding.statusText.text = "没有可用的 restic 快照" + updateStatus("没有可用的 restic 快照") setRunning(false) return@launch } @@ -185,7 +209,7 @@ class RestoreFragment : Fragment() { snapshots.first() } else { pickSnapshot(snapshots) ?: run { - binding.statusText.text = "已取消选择" + updateStatus("已取消选择") setRunning(false) return@launch } @@ -195,7 +219,7 @@ class RestoreFragment : Fragment() { backupDir = null selectedSnapshot = chosenSnapshot val backupPath = selectedSnapshot!!.paths.firstOrNull() ?: run { - binding.statusText.text = "快照中找不到备份路径" + updateStatus("快照中找不到备份路径") setRunning(false) return@launch } @@ -209,7 +233,7 @@ class RestoreFragment : Fragment() { } if (packages.isEmpty()) { - binding.statusText.text = "无法从快照读取应用列表" + updateStatus("无法从快照读取应用列表") setRunning(false) return@launch } @@ -220,9 +244,9 @@ class RestoreFragment : Fragment() { selectedPackages.addAll(packages) // 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 setRunning(false) setupAppList() @@ -385,6 +409,10 @@ class RestoreFragment : Fragment() { binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE } + private suspend fun updateStatus(text: String) { + binding.statusText.text = text + } + override fun onDestroyView() { super.onDestroyView() _binding = null diff --git a/app/src/main/res/layout/fragment_backup.xml b/app/src/main/res/layout/fragment_backup.xml index c173dbc..842711a 100644 --- a/app/src/main/res/layout/fragment_backup.xml +++ b/app/src/main/res/layout/fragment_backup.xml @@ -4,7 +4,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" - android:padding="16dp" + android:padding="@dimen/fragment_horizontal_padding" android:background="?attr/colorSurface"> @@ -129,6 +131,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" + android:maxLines="3" + android:ellipsize="end" android:text="点击扫描以载入应用列表" android:textAppearance="@style/TextAppearance.Material3.BodySmall" android:textColor="?attr/colorOnSurfaceVariant" /> diff --git a/app/src/main/res/layout/fragment_config.xml b/app/src/main/res/layout/fragment_config.xml index 213f6b5..6fa3d33 100644 --- a/app/src/main/res/layout/fragment_config.xml +++ b/app/src/main/res/layout/fragment_config.xml @@ -5,7 +5,7 @@ android:layout_height="match_parent" android:background="?attr/colorSurface" android:clipToPadding="false" - android:padding="16dp"> + android:padding="@dimen/fragment_horizontal_padding"> - + android:scrollbars="none"> - + app:singleSelection="true" + app:selectionRequired="true"> - + - + - + - + + + + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index af13160..5db1111 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -46,6 +46,8 @@ @color/surfaceContainerHigh @color/surfaceContainerHighest + + always @android:color/transparent @android:color/transparent diff --git a/app/src/main/res/values-sw600dp/dimens.xml b/app/src/main/res/values-sw600dp/dimens.xml new file mode 100644 index 0000000..09079aa --- /dev/null +++ b/app/src/main/res/values-sw600dp/dimens.xml @@ -0,0 +1,17 @@ + + + + 24dp + 16dp + 16dp + 12dp + + + 18sp + + + 24dp + + + 0dp + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..8aa6e05 --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,17 @@ + + + + 16dp + 12dp + 12dp + 8dp + + + 15sp + + + 16dp + + + 0dp + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 9583a80..f42693e 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -47,6 +47,8 @@ @color/surfaceContainerHigh @color/surfaceContainerHighest + + always @android:color/transparent @android:color/transparent diff --git a/build.gradle b/build.gradle index f2cbf17..4132547 100644 --- a/build.gradle +++ b/build.gradle @@ -4,8 +4,10 @@ buildscript { repositories { google() mavenCentral() + gradlePluginPortal() } dependencies { + classpath "org.jetbrains.kotlinx:kover-gradle-plugin:0.9.8" classpath 'com.android.tools.build:gradle:8.2.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" diff --git a/docs/superpowers/plans/2026-06-02-android-backup-optimization.md b/docs/superpowers/plans/2026-06-02-android-backup-optimization.md new file mode 100644 index 0000000..eb9f52f --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-android-backup-optimization.md @@ -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 = Result +// 后续步骤逐步替换为自定义 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 +// → suspend fun upload(...): AppResult +// 原来: suspend fun listFiles(...): Result> +// → suspend fun listFiles(...): AppResult> +// 原来: suspend fun exists(...): Result +// → suspend fun exists(...): AppResult +// 原来: class FileNotFoundException(path: String) : Exception("Directory not found: $path") +// → 删除整个类 + +// Result 保持 kotlin.Result 作为 AppResult,但创建 err 辅助函数 +// RemoteTransport.kt 末尾追加 +internal fun err(error: AppError): AppResult = + Result.failure(RuntimeException(error.message).also { /* AppError marker — 后续步骤用 sealed result 替换 */ }) +``` + +- [ ] **替换 WebdavTransport.upload — 使用 AppError** + +```kotlin +// WebdavTransport.kt — upload 方法 +override suspend fun upload(...): AppResult = + 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 +// → suspend fun upload(..., onProgress: FlowCollector? = null): AppResult +// +// 但为了与当前调用方兼容,改用 SharedFlow 模式: +// 保持 suspend fun upload(...): AppResult +// 创建一个挂起辅助函数,返回 Flow + +// 新增扩展方法: +suspend fun RemoteTransport.uploadWithFlow( + localPath: String, + remotePath: String +): Flow = 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(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 withRemoteSync( + // ... 参数不变 ... +): Result { + 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(Channel.CONFLATED) + val progressJob = launch(Dispatchers.Main) { + for (event in progressChannel) { + when (event) { + is TransferEvent.Phase -> onProgress(event.progress) + is TransferEvent.Bytes -> { + val now = System.currentTimeMillis() + if (!onByteProgress.isNoop && now - lastByteEmitMs >= 50) { + onByteProgress(event.progress) + lastByteEmitMs = now + } + } + } + } + } + + // 包装 emitProgress + val emitProgress: suspend (TransferProgress) -> Unit = { p -> + progressChannel.send(TransferEvent.Phase(p)) + } + val emitByteProgress: suspend (ByteProgress) -> Unit = { b -> + progressChannel.send(TransferEvent.Bytes(ByteProgress(b.bytesTransferred, b.totalBytes, b.currentFile))) + } + + // ... 原有 sync/action 逻辑,用 emitProgress 和 emitByteProgress ... + // 注意原代码的 action() 是同步调用,需要包在 coroutineScope 内 + } + // ... 后续逻辑 ... + } + } +} +``` + +- [ ] **验证编译通过并运行基本功能** + +Run: `./gradlew assembleDebug` +Expected: BUILD SUCCESSFUL + +- [ ] **Commit** + +```bash +git commit -a -m "perf: batch Main-thread progress emits via CONFLATED Channel with 50ms throttle" +``` + +--- + +### Task 4: 协程优化 — 结构化并发与取消 + +**Files:** +- Modify: `app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt` +- Modify: `app/src/main/java/com/example/androidbackupgui/backup/RootShell.kt` +- Modify: `app/src/main/java/com/example/androidbackupgui/ui/ConfigViewModel.kt` + +- [ ] **BackupOperation.backupApps — 确保协程取消传播** + +```kotlin +// BackupOperation.kt — backupApps 方法 +// 该方法使用 withContext(Dispatchers.IO) + Semaphore + 内部的 launch +// 问题: launch 在 withContext 内启动,如果不持有 Job 句柄,取消无法传播 + +// 修改: 用 coroutineScope 代替裸 launch +// 原来: +// launch { +// semaphore.withPermit { +// backupSingleApp(...) +// } +// } +// → coroutineScope { +// launch { +// semaphore.withPermit { +// backupSingleApp(...) +// } +// } +// } + +// 更优: 用 map + async + Semaphore 替代 launch 集合 +val deferreds = apps.map { app -> + async(backupSemaphore.asContextElement()) { + backupSingleApp(context, app, config, outputDir, userId, onProgress) + } +} +val results = deferreds.awaitAll() +``` + +- [ ] **RootShell.exec — 使用 ensureActive 替代被动超时** + +```kotlin +// RootShell.kt — exec 方法 +// 当前: 靠 withTimeout(120s) 兜底 +// 在等待过程中添加 ensureActive 检查 + +// 在多条命令场景(如备份数据)添加: +// ensureActive() // 在 runTar 循环内部 +``` + +- [ ] **ConfigViewModel — 使用 WhileSubscribed 替代 WhileStarted** + +```kotlin +// ConfigViewModel.kt +// 当前可能使用 stateIn(WhileSubscribed(0)) 或默认 +// 改为 WhileSubscribed(5000) 保证配置变更存活 5 秒 +// 具体取决于当前代码 + +// 检查当前 SharingStarted 模式并优化 +// 如果已经是 WhileSubscribed(5000),跳过 +``` + +Run: `./gradlew assembleDebug` +Expected: BUILD SUCCESSFUL + +- [ ] **Commit** + +```bash +git commit -a -m "refactor: ensure structured concurrency in BackupOperation and cancellation propagation" +``` + +--- + +### Task 5: 安全加固 — Root shell 注入防护 + +**Files:** +- Modify: `app/src/main/java/com/example/androidbackupgui/root/RootShell.kt` +- Modify: `app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt` +- Modify: `app/src/main/java/com/example/androidbackupgui/backup/WifiManager.kt` + +- [ ] **审计所有 RootShell.exec 调用方** + +用搜索找到所有 `RootShell.exec(` 或 `RootShell.exec("` 调用: + +```bash +# 搜索所有 root shell 调用 +# 在项目中搜索 RootShell.exec +``` + +当前已知的 root shell 调用点: +1. `WifiManager.kt`: `cp '$wifiSource' '${wifiDest.absolutePath.shellEscape()}'` — wifiDest 已 shellEscape,wifiSource 从预定义列表来(安全) +2. `BackupOperation.kt`: 多处 `pm path`、`dumpsys package`、`cp`、`tar`、`ls`、`rm` — 输入中 packageName 来自 `AppScanner`(非用户输入,安全),但 file path 拼接需要确认 shellEscape +3. `SELinuxUtil.kt`: `restorecon` 命令 + +- [ ] **为所有 root shell 参数统一使用 shellEscape 扩展函数** + +```kotlin +// 当前 shellEscape 已经存在 RootShell.kt 中 +// 审计每个 RootShell.exec 调用的参数是否穿过了 shellEscape() + +// 在 BackupOperation.runTar 中: +// 当前 val cmd = "tar ... '$excludesStr' ..." +// 确认 excludes 路径都经过了 shellEscape +``` + +- [ ] **创建 RootShell.exec 安全包装** + +```kotlin +// RootShell.kt — 添加安全执行方法 +// 禁止直接 exec 字符串拼接;提供 vararg 参数形式 + +/** + * 安全执行 root shell 命令,自动转义参数。 + * @param commandFmt 命令格式,用 {N} 占位(而非 $N 避免 shell 解析) + * @param args 参数列表,自动 shellEscape + */ +suspend fun execSafe( + commandParts: List, + 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() + + /** 在 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): List { +// val cmd = listOf(binaryPath) + args +// Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args → cmd=$cmd") +// return cmd +// } +// +// 改为表达式体: +fun buildCommandArgs(args: List): List = + (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 中修改后后续步骤保持一致引用。 diff --git a/kmboxnet b/kmboxnet new file mode 160000 index 0000000..9b62283 --- /dev/null +++ b/kmboxnet @@ -0,0 +1 @@ +Subproject commit 9b62283c6271e8e594f3b97986fed291deb4318f