refactor: Result → AppResult, DomainTypes, cleanup, and other improvements
- Replace kotlin.Result with AppResult across all transports and operations - Introduce DomainTypes (PackageName, AppInfo) for type safety - Add AppError sealed hierarchy for structured error handling - Add SELinuxUtil for SELinux context restoration - Add values-sw600dp and dimens.xml for tablet layout support - Sync progress UI refactoring in BackupFragment/RestoreFragment - BinaryResolver per-binary cache fix
This commit is contained in:
@@ -1,6 +1,23 @@
|
|||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
apply plugin: 'kotlin-android'
|
apply plugin: 'kotlin-android'
|
||||||
apply plugin: 'kotlinx-serialization'
|
apply plugin: 'kotlinx-serialization'
|
||||||
|
apply plugin: 'org.jetbrains.kotlinx.kover'
|
||||||
|
|
||||||
|
kover {
|
||||||
|
reports {
|
||||||
|
filters {
|
||||||
|
excludes {
|
||||||
|
classes(
|
||||||
|
// Generated/auto classes
|
||||||
|
"*.databinding.*",
|
||||||
|
"*.BuildConfig",
|
||||||
|
"*.R",
|
||||||
|
"*.R\$*"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace "com.example.androidbackupgui"
|
namespace "com.example.androidbackupgui"
|
||||||
@@ -38,6 +55,12 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
testOptions {
|
||||||
|
unitTests.all {
|
||||||
|
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
@@ -72,4 +95,9 @@ dependencies {
|
|||||||
|
|
||||||
// root shell via libsu (Magisk/KernelSU/APatch)
|
// root shell via libsu (Magisk/KernelSU/APatch)
|
||||||
implementation 'com.github.topjohnwu:libsu:6.0.0'
|
implementation 'com.github.topjohnwu:libsu:6.0.0'
|
||||||
|
testImplementation "io.kotest:kotest-runner-junit5:5.9.1"
|
||||||
|
testImplementation "io.kotest:kotest-assertions-core:5.9.1"
|
||||||
|
testImplementation "io.kotest:kotest-property:5.9.1"
|
||||||
|
testImplementation "io.mockk:mockk:1.13.12"
|
||||||
|
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package com.example.androidbackupgui.backup
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类型化应用错误层次。所有业务层错误统一为此 sealed interface。
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* ```
|
||||||
|
* // 失败返回
|
||||||
|
* return err(AppError.Remote("连接超时", "download", cause = e, retryable = true))
|
||||||
|
*
|
||||||
|
* // 模式匹配
|
||||||
|
* when (error) {
|
||||||
|
* is AppError.Network -> showRetry()
|
||||||
|
* is AppError.Remote -> handleRemote(error)
|
||||||
|
* is AppError.Cancelled -> ignore()
|
||||||
|
* else -> showError(error.message)
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
sealed interface AppError {
|
||||||
|
|
||||||
|
/** 人类可读的错误描述 */
|
||||||
|
val message: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网络/IO 类错误。
|
||||||
|
* 用于 HTTP 请求超时、DNS 解析失败、连接被拒绝等可重试的网络异常。
|
||||||
|
*
|
||||||
|
* @property retryable 默认为 true,表示此错误可安全重试
|
||||||
|
*/
|
||||||
|
data class Network(
|
||||||
|
override val message: String,
|
||||||
|
val cause: Throwable? = null,
|
||||||
|
val retryable: Boolean = true
|
||||||
|
) : AppError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root shell 命令执行错误。
|
||||||
|
* 用于 cp、tar、pm path、dumpsys 等 root 命令的非零退出。
|
||||||
|
*/
|
||||||
|
data class Shell(
|
||||||
|
override val message: String,
|
||||||
|
val command: String,
|
||||||
|
val exitCode: Int,
|
||||||
|
val stderr: String
|
||||||
|
) : AppError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 远端文件操作错误(WebDAV/SMB)。
|
||||||
|
* 用于上传、下载、列出、删除远端文件时的协议层错误。
|
||||||
|
*
|
||||||
|
* @property phase 错误发生时所在的阶段,可取 "connecting"、"transferring"、"list"、"delete" 等
|
||||||
|
* @property isNotFound 远端路径是否存在(区分 404 和其他错误)
|
||||||
|
* @property retryable 默认为 false,明确标记为可重试需业务层判断
|
||||||
|
*/
|
||||||
|
data class Remote(
|
||||||
|
override val message: String,
|
||||||
|
val phase: String,
|
||||||
|
val cause: Throwable? = null,
|
||||||
|
val isNotFound: Boolean = false,
|
||||||
|
val retryable: Boolean = false
|
||||||
|
) : AppError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地文件/IO 错误。
|
||||||
|
* 用于文件读写失败、磁盘空间不足、文件不存在等本地文件系统错误。
|
||||||
|
*/
|
||||||
|
data class LocalIO(
|
||||||
|
override val message: String,
|
||||||
|
val path: String,
|
||||||
|
val cause: Throwable? = null
|
||||||
|
) : AppError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* restic 命令执行错误。
|
||||||
|
* 用于 restic backup / restore / snapshots / forget 等子命令返回非零退出码。
|
||||||
|
*/
|
||||||
|
data class Restic(
|
||||||
|
override val message: String,
|
||||||
|
val exitCode: Int,
|
||||||
|
val stderr: String
|
||||||
|
) : AppError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析/配置错误。
|
||||||
|
* 用于 JSON 解析失败、配置文件格式错误、参数校验失败等场景。
|
||||||
|
*/
|
||||||
|
data class Parse(
|
||||||
|
override val message: String,
|
||||||
|
val detail: String = ""
|
||||||
|
) : AppError
|
||||||
|
|
||||||
|
/** 操作被取消(用户中止或协程取消)。不应重试。 */
|
||||||
|
data object Cancelled : AppError {
|
||||||
|
override val message: String = "操作被取消"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与 [AppError] 配套的类型化返回类型。
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* ```
|
||||||
|
* fun load(): AppResult<List<Item>> {
|
||||||
|
* return AppResult.Success(items)
|
||||||
|
* // 或
|
||||||
|
* return err(AppError.Network("连接失败"))
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // 消费
|
||||||
|
* when (val result = load()) {
|
||||||
|
* is AppResult.Success -> showItems(result.data)
|
||||||
|
* is AppResult.Failure -> showError(result.error.message)
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // 或使用 fold / map
|
||||||
|
* result.fold(
|
||||||
|
* onSuccess = { items -> showItems(items) },
|
||||||
|
* onFailure = { error -> showError(error.message) }
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
sealed class AppResult<out T> {
|
||||||
|
data class Success<T>(val data: T) : AppResult<T>()
|
||||||
|
data class Failure(val error: AppError) : AppResult<Nothing>()
|
||||||
|
|
||||||
|
/** Returns `true` if this is a [Success]. */
|
||||||
|
val isSuccess: Boolean get() = this is Success
|
||||||
|
|
||||||
|
/** Returns `true` if this is a [Failure]. */
|
||||||
|
val isFailure: Boolean get() = this is Failure
|
||||||
|
|
||||||
|
/** Returns the success value, or `null` if this is a [Failure]. */
|
||||||
|
fun getOrNull(): T? = (this as? Success)?.data
|
||||||
|
|
||||||
|
/** Returns the success value, or [default] if this is a [Failure]. */
|
||||||
|
fun getOrDefault(default: @UnsafeVariance T): T =
|
||||||
|
(this as? Success)?.data ?: default
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the success value, or throws a [RuntimeException]
|
||||||
|
* wrapping the error message if this is a [Failure].
|
||||||
|
*/
|
||||||
|
fun getOrThrow(): T =
|
||||||
|
(this as? Success)?.data
|
||||||
|
?: throw RuntimeException((this as Failure).error.message)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a [RuntimeException] representing the error, or `null` if this is a [Success].
|
||||||
|
* Callers can access `.message` on the result.
|
||||||
|
*/
|
||||||
|
fun exceptionOrNull(): Throwable? =
|
||||||
|
(this as? Failure)?.let { RuntimeException(it.error.message) }
|
||||||
|
|
||||||
|
/** Returns the [AppError], or `null` if this is a [Success]. */
|
||||||
|
fun errorOrNull(): AppError? = (this as? Failure)?.error
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold: convert either branch into a single value [R].
|
||||||
|
* [onSuccess] receives the success value; [onFailure] receives the typed [AppError].
|
||||||
|
*/
|
||||||
|
inline fun <R> fold(
|
||||||
|
crossinline onSuccess: (T) -> R,
|
||||||
|
crossinline onFailure: (AppError) -> R,
|
||||||
|
): R = when (this) {
|
||||||
|
is Success -> onSuccess(data)
|
||||||
|
is Failure -> onFailure(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <R> map(crossinline transform: (T) -> R): AppResult<R> = when (this) {
|
||||||
|
is Success -> Success(transform(data))
|
||||||
|
is Failure -> this
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform the error using [transform], or pass through the success unchanged.
|
||||||
|
*/
|
||||||
|
fun mapError(transform: (AppError) -> AppError): AppResult<T> = when (this) {
|
||||||
|
is Success -> this
|
||||||
|
is Failure -> Failure(transform(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a failed [AppResult] wrapping the given [AppError].
|
||||||
|
*/
|
||||||
|
internal fun <T> err(error: AppError): AppResult<T> = AppResult.Failure(error)
|
||||||
@@ -19,7 +19,7 @@ data class DataSizes(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AppInfo(
|
data class AppInfo(
|
||||||
val packageName: String,
|
val packageName: PackageName,
|
||||||
var label: String = "",
|
var label: String = "",
|
||||||
val isSystem: Boolean = false,
|
val isSystem: Boolean = false,
|
||||||
val apkPaths: List<String> = emptyList(),
|
val apkPaths: List<String> = emptyList(),
|
||||||
@@ -27,7 +27,7 @@ data class AppInfo(
|
|||||||
val isRunning: Boolean = false,
|
val isRunning: Boolean = false,
|
||||||
val backupSize: Long = 0, // estimated from last backup
|
val backupSize: Long = 0, // estimated from last backup
|
||||||
// Enhanced fields (multi-user, keystore, icon)
|
// Enhanced fields (multi-user, keystore, icon)
|
||||||
val userId: Int = 0,
|
val userId: UserId = UserId(0),
|
||||||
val hasKeystore: Boolean = false,
|
val hasKeystore: Boolean = false,
|
||||||
val iconPath: String? = null,
|
val iconPath: String? = null,
|
||||||
val dataSizes: DataSizes = DataSizes(),
|
val dataSizes: DataSizes = DataSizes(),
|
||||||
@@ -44,11 +44,10 @@ object AppScanner {
|
|||||||
.filter { it.startsWith("package:") }
|
.filter { it.startsWith("package:") }
|
||||||
.map { it.removePrefix("package:").trim() }
|
.map { it.removePrefix("package:").trim() }
|
||||||
.filter { it.isNotEmpty() }
|
.filter { it.isNotEmpty() }
|
||||||
.map { AppInfo(packageName = it, userId = userId) }
|
.map { AppInfo(packageName = PackageName(it), userId = UserId(userId)) }
|
||||||
resolveLabels(context, packages)
|
resolveLabels(context, packages)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scan all system packages. */
|
|
||||||
suspend fun scanSystem(context: Context, config: BackupConfig, userId: Int = 0): List<AppInfo> = withContext(Dispatchers.IO) {
|
suspend fun scanSystem(context: Context, config: BackupConfig, userId: Int = 0): List<AppInfo> = withContext(Dispatchers.IO) {
|
||||||
val result = RootShell.exec("pm list packages -s --user $userId")
|
val result = RootShell.exec("pm list packages -s --user $userId")
|
||||||
if (!result.isSuccess) return@withContext emptyList()
|
if (!result.isSuccess) return@withContext emptyList()
|
||||||
@@ -67,7 +66,7 @@ object AppScanner {
|
|||||||
.filter { pkg ->
|
.filter { pkg ->
|
||||||
if (config.blacklistMode == 1) pkg !in blacklist else true
|
if (config.blacklistMode == 1) pkg !in blacklist else true
|
||||||
}
|
}
|
||||||
.map { AppInfo(packageName = it, isSystem = true, userId = userId) }
|
.map { AppInfo(packageName = PackageName(it), isSystem = true, userId = UserId(userId)) }
|
||||||
resolveLabels(context, packages)
|
resolveLabels(context, packages)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,10 +81,10 @@ object AppScanner {
|
|||||||
val pm = context.packageManager
|
val pm = context.packageManager
|
||||||
for (app in packages) {
|
for (app in packages) {
|
||||||
app.label = try {
|
app.label = try {
|
||||||
val ai = pm.getApplicationInfo(app.packageName, 0)
|
val ai = pm.getApplicationInfo(app.packageName.value, 0)
|
||||||
pm.getApplicationLabel(ai).toString()
|
pm.getApplicationLabel(ai).toString()
|
||||||
} catch (_: PackageManager.NameNotFoundException) {
|
} catch (_: PackageManager.NameNotFoundException) {
|
||||||
app.packageName
|
app.packageName.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return packages
|
return packages
|
||||||
@@ -127,7 +126,7 @@ object AppScanner {
|
|||||||
/** Check if an app has keystore entries (critical — keystore keys can be lost on backup). */
|
/** Check if an app has keystore entries (critical — keystore keys can be lost on backup). */
|
||||||
suspend fun hasKeystore(packageName: String): Boolean = withContext(Dispatchers.IO) {
|
suspend fun hasKeystore(packageName: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
// Resolve the app's UID first
|
// Resolve the app's UID first
|
||||||
val uidResult = RootShell.exec("dumpsys package '$packageName' | grep 'userId=' | head -1")
|
val uidResult = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep 'userId=' | head -1")
|
||||||
val uid = uidResult.output
|
val uid = uidResult.output
|
||||||
.substringAfter("userId=", "")
|
.substringAfter("userId=", "")
|
||||||
.substringBefore(" ")
|
.substringBefore(" ")
|
||||||
@@ -156,11 +155,11 @@ object AppScanner {
|
|||||||
suspend fun extractIcon(packageName: String, destDir: java.io.File, userId: Int = 0): String? = withContext(Dispatchers.IO) {
|
suspend fun extractIcon(packageName: String, destDir: java.io.File, userId: Int = 0): String? = withContext(Dispatchers.IO) {
|
||||||
// Try snapshot cache first
|
// Try snapshot cache first
|
||||||
val snapshotDir = "/data/system_ce/$userId/snapshots/$packageName"
|
val snapshotDir = "/data/system_ce/$userId/snapshots/$packageName"
|
||||||
val snapshotResult = RootShell.exec("ls '$snapshotDir/' 2>/dev/null | head -1")
|
val snapshotResult = RootShell.exec("ls '${snapshotDir.shellEscape()}/' 2>/dev/null | head -1")
|
||||||
if (snapshotResult.isSuccess && snapshotResult.output.isNotBlank()) {
|
if (snapshotResult.isSuccess && snapshotResult.output.isNotBlank()) {
|
||||||
val iconName = snapshotResult.output.trim()
|
val iconName = snapshotResult.output.trim()
|
||||||
val iconFile = java.io.File(destDir, "app_icon.png")
|
val iconFile = java.io.File(destDir, "app_icon.png")
|
||||||
val copyResult = RootShell.exec("cp '${snapshotDir}/${iconName.shellEscape()}' '${iconFile.absolutePath.shellEscape()}' 2>/dev/null")
|
val copyResult = RootShell.exec("cp '${snapshotDir.shellEscape()}/${iconName.shellEscape()}' '${iconFile.absolutePath.shellEscape()}' 2>/dev/null")
|
||||||
if (copyResult.isSuccess && iconFile.exists()) {
|
if (copyResult.isSuccess && iconFile.exists()) {
|
||||||
return@withContext iconFile.absolutePath
|
return@withContext iconFile.absolutePath
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,36 +6,38 @@ import kotlinx.serialization.Serializable
|
|||||||
/**
|
/**
|
||||||
* Mirrors backup_settings.conf from backup_script.
|
* Mirrors backup_settings.conf from backup_script.
|
||||||
* All keys correspond 1:1 with the original shell config.
|
* All keys correspond 1:1 with the original shell config.
|
||||||
|
*
|
||||||
|
* This is an immutable data class. Use [copy] to create modified instances.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class BackupConfig(
|
data class BackupConfig(
|
||||||
// Operation mode
|
// Operation mode
|
||||||
var lo: Int = 0, // 0=volume key, 1=volume force, 2=keyboard
|
val lo: Int = 0, // 0=volume key, 1=volume force, 2=keyboard
|
||||||
var backgroundExecution: Int = 0, // 0=foreground, 1=background
|
val backgroundExecution: Int = 0, // 0=foreground, 1=background
|
||||||
var setDisplayPowerMode: Int = 0, // 1=keep screen on during backup
|
val setDisplayPowerMode: Int = 0, // 1=keep screen on during backup
|
||||||
var shellLang: String = "", // ""=auto, "1"=zh-CN, "0"=zh-TW
|
val shellLang: String = "", // ""=auto, "1"=zh-CN, "0"=zh-TW
|
||||||
|
|
||||||
// Paths
|
// Paths
|
||||||
var outputPath: String = "", // Custom output dir
|
val outputPath: String = "", // Custom output dir
|
||||||
var listLocation: String = "", // Custom appList.txt location
|
val listLocation: String = "", // Custom appList.txt location
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
var update: Int = 1, // 1=auto update
|
val update: Int = 1, // 1=auto update
|
||||||
var cdn: Int = 1, // CDN node
|
val cdn: Int = 1, // CDN node
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
var mountPoint: String = "rannki|0000-1",
|
val mountPoint: String = "rannki|0000-1",
|
||||||
var user: String = "",
|
val user: String = "",
|
||||||
|
|
||||||
// Backup mode
|
// Backup mode
|
||||||
var backupMode: Int = 1, // 1=data+apk, 0=apk only
|
val backupMode: Int = 1, // 1=data+apk, 0=apk only
|
||||||
var backupUserData: Int = 1,
|
val backupUserData: Int = 1,
|
||||||
var backupObbData: Int = 1,
|
val backupObbData: Int = 1,
|
||||||
var backupMedia: Int = 0,
|
val backupMedia: Int = 0,
|
||||||
var backgroundAppsIgnore: Int = 0,
|
val backgroundAppsIgnore: Int = 0,
|
||||||
|
|
||||||
// Custom paths
|
// Custom paths
|
||||||
var customPath: List<String> = listOf(
|
val customPath: List<String> = listOf(
|
||||||
"/storage/emulated/0/Pictures/",
|
"/storage/emulated/0/Pictures/",
|
||||||
"/storage/emulated/0/Download/",
|
"/storage/emulated/0/Download/",
|
||||||
"/storage/emulated/0/Music",
|
"/storage/emulated/0/Music",
|
||||||
@@ -44,38 +46,37 @@ data class BackupConfig(
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Blacklist
|
// Blacklist
|
||||||
var blacklistMode: Int = 0, // 1=full ignore, 0=apk only
|
val blacklistMode: Int = 0, // 1=full ignore, 0=apk only
|
||||||
var blacklist: List<String> = emptyList(),
|
val blacklist: List<String> = emptyList(),
|
||||||
|
|
||||||
// Whitelists
|
// Whitelists
|
||||||
var whitelist: List<String> = emptyList(),
|
val whitelist: List<String> = emptyList(),
|
||||||
var system: List<String> = emptyList(),
|
val system: List<String> = emptyList(),
|
||||||
|
|
||||||
// Compression
|
// Compression
|
||||||
var compressionMethod: String = "zstd", // zstd or tar
|
val compressionMethod: String = "zstd", // zstd or tar
|
||||||
|
|
||||||
// Terminal colors
|
// Terminal colors
|
||||||
var rgbA: Int = 226,
|
val rgbA: Int = 226,
|
||||||
var rgbB: Int = 123,
|
val rgbB: Int = 123,
|
||||||
var rgbC: Int = 177,
|
val rgbC: Int = 177,
|
||||||
|
|
||||||
var backupWifi: Int = 1,
|
val backupWifi: Int = 1,
|
||||||
|
|
||||||
// Restic deduplicated backup with rclone backend
|
// Restic deduplicated backup with rclone backend
|
||||||
var resticEnabled: Int = 0,
|
val resticEnabled: Int = 0,
|
||||||
var resticRepo: String = "",
|
val resticRepo: String = "",
|
||||||
var resticPassword: String = "",
|
val resticPassword: String = "",
|
||||||
var resticBackend: String = "local", // local / webdav / smb
|
val resticBackend: String = "local", // local / webdav / smb
|
||||||
var resticBackendUrl: String = "",
|
val resticBackendUrl: String = "",
|
||||||
var resticBackendUser: String = "",
|
val resticBackendUser: String = "",
|
||||||
var resticBackendPass: String = "",
|
val resticBackendPass: String = "",
|
||||||
var resticBackendShare: String = "", // SMB share name
|
val resticBackendShare: String = "", // SMB share name
|
||||||
var resticBackendDomain: String = "" // SMB domain (optional, for NTLM)
|
val resticBackendDomain: String = "" // SMB domain (optional, for NTLM)
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromFile(file: File): BackupConfig {
|
fun fromFile(file: File): BackupConfig {
|
||||||
val config = BackupConfig()
|
if (!file.exists()) return BackupConfig()
|
||||||
if (!file.exists()) return config
|
|
||||||
|
|
||||||
val props = mutableMapOf<String, String>()
|
val props = mutableMapOf<String, String>()
|
||||||
file.forEachLine { line ->
|
file.forEachLine { line ->
|
||||||
@@ -97,41 +98,42 @@ data class BackupConfig(
|
|||||||
.map { it.replace("%20", " ") }
|
.map { it.replace("%20", " ") }
|
||||||
}
|
}
|
||||||
|
|
||||||
config.lo = int("Lo")
|
return BackupConfig(
|
||||||
config.backgroundExecution = int("background_execution")
|
lo = int("Lo"),
|
||||||
config.setDisplayPowerMode = int("setDisplayPowerMode")
|
backgroundExecution = int("background_execution"),
|
||||||
config.shellLang = str("Shell_LANG")
|
setDisplayPowerMode = int("setDisplayPowerMode"),
|
||||||
config.outputPath = str("Output_path")
|
shellLang = str("Shell_LANG"),
|
||||||
config.listLocation = str("list_location")
|
outputPath = str("Output_path"),
|
||||||
config.update = int("update", default = 1)
|
listLocation = str("list_location"),
|
||||||
config.cdn = int("cdn", default = 1)
|
update = int("update", default = 1),
|
||||||
config.mountPoint = str("mount_point")
|
cdn = int("cdn", default = 1),
|
||||||
config.user = str("user")
|
mountPoint = str("mount_point"),
|
||||||
config.backupMode = int("Backup_Mode", default = 1)
|
user = str("user"),
|
||||||
config.backupUserData = int("Backup_user_data", default = 1)
|
backupMode = int("Backup_Mode", default = 1),
|
||||||
config.backupObbData = int("Backup_obb_data", default = 1)
|
backupUserData = int("Backup_user_data", default = 1),
|
||||||
config.backupMedia = int("backup_media")
|
backupObbData = int("Backup_obb_data", default = 1),
|
||||||
config.backgroundAppsIgnore = int("Background_apps_ignore")
|
backupMedia = int("backup_media"),
|
||||||
config.customPath = lines("Custom_path")
|
backgroundAppsIgnore = int("Background_apps_ignore"),
|
||||||
config.blacklistMode = int("blacklist_mode")
|
customPath = lines("Custom_path"),
|
||||||
config.blacklist = lines("blacklist")
|
blacklistMode = int("blacklist_mode"),
|
||||||
config.whitelist = lines("whitelist")
|
blacklist = lines("blacklist"),
|
||||||
config.system = lines("system")
|
whitelist = lines("whitelist"),
|
||||||
config.compressionMethod = str("Compression_method").ifEmpty { "zstd" }
|
system = lines("system"),
|
||||||
config.rgbA = int("rgb_a").let { if (it == 0) 226 else it }
|
compressionMethod = str("Compression_method").ifEmpty { "zstd" },
|
||||||
config.rgbB = int("rgb_b").let { if (it == 0) 123 else it }
|
rgbA = int("rgb_a").let { if (it == 0) 226 else it },
|
||||||
config.rgbC = int("rgb_c").let { if (it == 0) 177 else it }
|
rgbB = int("rgb_b").let { if (it == 0) 123 else it },
|
||||||
config.backupWifi = int("backup_wifi", default = 1)
|
rgbC = int("rgb_c").let { if (it == 0) 177 else it },
|
||||||
config.resticEnabled = int("restic_enabled")
|
backupWifi = int("backup_wifi", default = 1),
|
||||||
config.resticRepo = str("restic_repo")
|
resticEnabled = int("restic_enabled"),
|
||||||
config.resticPassword = str("restic_password")
|
resticRepo = str("restic_repo"),
|
||||||
config.resticBackend = str("restic_backend").ifEmpty { "local" }
|
resticPassword = str("restic_password"),
|
||||||
config.resticBackendUrl = str("restic_backend_url")
|
resticBackend = str("restic_backend").ifEmpty { "local" },
|
||||||
config.resticBackendUser = str("restic_backend_user")
|
resticBackendUrl = str("restic_backend_url"),
|
||||||
config.resticBackendPass = str("restic_backend_pass")
|
resticBackendUser = str("restic_backend_user"),
|
||||||
config.resticBackendShare = str("restic_backend_share")
|
resticBackendPass = str("restic_backend_pass"),
|
||||||
config.resticBackendDomain = str("restic_backend_domain")
|
resticBackendShare = str("restic_backend_share"),
|
||||||
return config
|
resticBackendDomain = str("restic_backend_domain"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toFile(config: BackupConfig, file: File) {
|
fun toFile(config: BackupConfig, file: File) {
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ package com.example.androidbackupgui.backup
|
|||||||
import com.example.androidbackupgui.root.RootShell
|
import com.example.androidbackupgui.root.RootShell
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.example.androidbackupgui.root.shellEscape
|
import com.example.androidbackupgui.root.shellEscape
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import kotlin.coroutines.coroutineContext
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
@@ -68,7 +68,7 @@ object BackupOperation {
|
|||||||
|
|
||||||
// Write app list
|
// Write app list
|
||||||
val appListFile = File(backupRoot, "appList.txt")
|
val appListFile = File(backupRoot, "appList.txt")
|
||||||
appListFile.writeText(apps.joinToString("\n") { it.packageName })
|
appListFile.writeText(apps.joinToString("\n") { it.packageName.value })
|
||||||
|
|
||||||
// Write metadata JSON
|
// Write metadata JSON
|
||||||
val metaFile = File(backupRoot, "app_details.json")
|
val metaFile = File(backupRoot, "app_details.json")
|
||||||
@@ -80,17 +80,17 @@ object BackupOperation {
|
|||||||
val skippedAtomic = AtomicInteger(0)
|
val skippedAtomic = AtomicInteger(0)
|
||||||
|
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
apps.forEachIndexed { index, app ->
|
apps.mapIndexed { index, app ->
|
||||||
launch {
|
async {
|
||||||
if (!coroutineContext.isActive) return@launch
|
|
||||||
semaphore.withPermit {
|
semaphore.withPermit {
|
||||||
val appDir = File(backupRoot, app.packageName)
|
ensureActive()
|
||||||
|
val appDir = File(backupRoot, app.packageName.value)
|
||||||
appDir.mkdirs()
|
appDir.mkdirs()
|
||||||
|
|
||||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "apk", "正在备份 APK…"))
|
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "apk", "正在备份 APK…"))
|
||||||
|
|
||||||
// 1. Backup APK
|
// 1. Backup APK
|
||||||
val paths = AppScanner.getApkPaths(app.packageName)
|
val paths = AppScanner.getApkPaths(app.packageName.value)
|
||||||
val apkOk = if (paths.isNotEmpty()) {
|
val apkOk = if (paths.isNotEmpty()) {
|
||||||
paths.withIndex().all { (i, apkPath) ->
|
paths.withIndex().all { (i, apkPath) ->
|
||||||
val destName = if (paths.size > 1) "${app.packageName}_split_$i.apk" else "${app.packageName}.apk"
|
val destName = if (paths.size > 1) "${app.packageName}_split_$i.apk" else "${app.packageName}.apk"
|
||||||
@@ -100,57 +100,57 @@ object BackupOperation {
|
|||||||
|
|
||||||
if (!apkOk) {
|
if (!apkOk) {
|
||||||
failAtomic.incrementAndGet()
|
failAtomic.incrementAndGet()
|
||||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "APK 备份失败"))
|
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "done", "APK 备份失败"))
|
||||||
return@withPermit
|
return@withPermit
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1.5 Keystore check — warn if app has keystore entries (keys can be lost)
|
// 1.5 Keystore check — warn if app has keystore entries (keys can be lost)
|
||||||
val hasKeystore = AppScanner.hasKeystore(app.packageName)
|
val hasKeystore = AppScanner.hasKeystore(app.packageName.value)
|
||||||
if (hasKeystore) {
|
if (hasKeystore) {
|
||||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "data", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失"))
|
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "data", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Backup user data (if configured)
|
// 2. Backup user data (if configured)
|
||||||
if (config.backupMode == 1 && config.backupUserData == 1) {
|
if (config.backupMode == 1 && config.backupUserData == 1) {
|
||||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "data", "正在备份数据…"))
|
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "data", "正在备份数据…"))
|
||||||
if (!backupUserData(context, app.packageName, appDir, userId, config.compressionMethod)) {
|
if (!backupUserData(context, app.packageName.value, appDir, userId, config.compressionMethod)) {
|
||||||
failAtomic.incrementAndGet()
|
failAtomic.incrementAndGet()
|
||||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "数据备份失败"))
|
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "done", "数据备份失败"))
|
||||||
return@withPermit
|
return@withPermit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Backup OBB (if configured and exists)
|
// 3. Backup OBB (if configured and exists)
|
||||||
if (config.backupMode == 1 && config.backupObbData == 1) {
|
if (config.backupMode == 1 && config.backupObbData == 1) {
|
||||||
val hasObb = AppScanner.hasObbData(app.packageName)
|
val hasObb = AppScanner.hasObbData(app.packageName.value)
|
||||||
if (hasObb) {
|
if (hasObb) {
|
||||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "obb", "正在备份 OBB…"))
|
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "obb", "正在备份 OBB…"))
|
||||||
if (!backupObb(app.packageName, appDir, config.compressionMethod)) {
|
if (!backupObb(app.packageName.value, appDir, config.compressionMethod)) {
|
||||||
failAtomic.incrementAndGet()
|
failAtomic.incrementAndGet()
|
||||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "OBB 备份失败"))
|
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "done", "OBB 备份失败"))
|
||||||
return@withPermit
|
return@withPermit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Backup SSAID
|
// 4. Backup SSAID
|
||||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "ssaid", "正在备份 SSAID…"))
|
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "ssaid", "正在备份 SSAID…"))
|
||||||
backupSsaid(app.packageName, appDir, userId)
|
backupSsaid(app.packageName.value, appDir, userId)
|
||||||
|
|
||||||
// 4.5 Backup app icon
|
// 4.5 Backup app icon
|
||||||
val iconPath = AppScanner.extractIcon(app.packageName, appDir, app.userId)
|
val iconPath = AppScanner.extractIcon(app.packageName.value, appDir, app.userId.value)
|
||||||
if (iconPath != null) {
|
if (iconPath != null) {
|
||||||
Log.d(TAG, "backupApps: saved icon for ${app.packageName} -> $iconPath")
|
Log.d(TAG, "backupApps: saved icon for ${app.packageName} -> $iconPath")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Backup runtime permissions
|
// 5. Backup runtime permissions
|
||||||
backupPermissions(app.packageName, appDir)
|
backupPermissions(app.packageName.value, appDir)
|
||||||
|
|
||||||
successAtomic.incrementAndGet()
|
successAtomic.incrementAndGet()
|
||||||
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "完成"))
|
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "done", "完成"))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}.awaitAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
val elapsed = System.currentTimeMillis() - startTime
|
val elapsed = System.currentTimeMillis() - startTime
|
||||||
@@ -209,7 +209,7 @@ object BackupOperation {
|
|||||||
var archiveCreated = false
|
var archiveCreated = false
|
||||||
var result: RootShell.ShellResult? = null
|
var result: RootShell.ShellResult? = null
|
||||||
|
|
||||||
val dirs = dataPaths.filter { RootShell.exec("test -d $it").isSuccess }.toMutableList()
|
val dirs = dataPaths.filter { RootShell.exec("test -d '${it.shellEscape()}'").isSuccess }.toMutableList()
|
||||||
if (dirs.isNotEmpty()) {
|
if (dirs.isNotEmpty()) {
|
||||||
Log.d(TAG, "backupUserData: $packageName test -d found dirs=$dirs")
|
Log.d(TAG, "backupUserData: $packageName test -d found dirs=$dirs")
|
||||||
result = runTar(dirs, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
|
result = runTar(dirs, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
|
||||||
@@ -227,9 +227,9 @@ object BackupOperation {
|
|||||||
Log.w(TAG, "backupUserData: $packageName step3 trying /proc/1/root")
|
Log.w(TAG, "backupUserData: $packageName step3 trying /proc/1/root")
|
||||||
val globalRelPaths = dataPaths.map { it.removePrefix("/") }
|
val globalRelPaths = dataPaths.map { it.removePrefix("/") }
|
||||||
val globalCmd = if (isZstd) {
|
val globalCmd = if (isZstd) {
|
||||||
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(" ")} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
|
"cd /proc/1/root && set -o pipefail; $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
|
||||||
} else {
|
} else {
|
||||||
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ")} 2>/dev/null"
|
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null"
|
||||||
}
|
}
|
||||||
result = RootShell.exec(globalCmd)
|
result = RootShell.exec(globalCmd)
|
||||||
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
|
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
|
||||||
@@ -275,12 +275,12 @@ object BackupOperation {
|
|||||||
excludes: List<String> = emptyList()
|
excludes: List<String> = emptyList()
|
||||||
): RootShell.ShellResult {
|
): RootShell.ShellResult {
|
||||||
val excludeArgs = if (excludes.isNotEmpty()) {
|
val excludeArgs = if (excludes.isNotEmpty()) {
|
||||||
excludes.joinToString(" ") { "--exclude='$it'" }
|
excludes.joinToString(" ") { "--exclude='${it.shellEscape()}'" }
|
||||||
} else ""
|
} else ""
|
||||||
return if (isZstd) {
|
return if (isZstd) {
|
||||||
RootShell.exec("$tarCmd -cf - $excludeArgs ${dirs.joinToString(" ")} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'")
|
RootShell.exec("set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'")
|
||||||
} else {
|
} else {
|
||||||
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ")} 2>/dev/null")
|
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private suspend fun backupObb(packageName: String, appDir: File, compression: String): Boolean {
|
private suspend fun backupObb(packageName: String, appDir: File, compression: String): Boolean {
|
||||||
@@ -290,7 +290,7 @@ object BackupOperation {
|
|||||||
// Exclude cache and backup temp files from OBB archive
|
// Exclude cache and backup temp files from OBB archive
|
||||||
val obbExcludes = "--exclude='cache' --exclude='Backup_*'"
|
val obbExcludes = "--exclude='cache' --exclude='Backup_*'"
|
||||||
val result = when (compression) {
|
val result = when (compression) {
|
||||||
"zstd" -> RootShell.exec("tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'")
|
"zstd" -> RootShell.exec("set -o pipefail; tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'")
|
||||||
else -> RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
|
else -> RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
|
||||||
}
|
}
|
||||||
if (!result.isSuccess) {
|
if (!result.isSuccess) {
|
||||||
@@ -343,7 +343,7 @@ object BackupOperation {
|
|||||||
val entry = JSONObject()
|
val entry = JSONObject()
|
||||||
entry.put("label", app.label)
|
entry.put("label", app.label)
|
||||||
entry.put("isSystem", app.isSystem)
|
entry.put("isSystem", app.isSystem)
|
||||||
root.put(app.packageName, entry)
|
root.put(app.packageName.value, entry)
|
||||||
}
|
}
|
||||||
return root.toString(2)
|
return root.toString(2)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,25 +12,28 @@ import java.io.File
|
|||||||
object BinaryResolver {
|
object BinaryResolver {
|
||||||
private const val TAG = "BinaryResolver"
|
private const val TAG = "BinaryResolver"
|
||||||
|
|
||||||
private val cacheTar = ResolveCache()
|
private var tarPath: String? = null
|
||||||
private val cacheZstd = ResolveCache()
|
private var zstdPath: String? = null
|
||||||
|
|
||||||
private class ResolveCache {
|
fun tarPath(context: Context): String? = cacheOrResolve(context, "libtar_bin.so", "tar_bin", ::tarPath) { tarPath = it }
|
||||||
var initialized = false
|
fun zstdPath(context: Context): String? = cacheOrResolve(context, "libzstd_bin.so", "zstd_bin", ::zstdPath) { zstdPath = it }
|
||||||
var path: String? = null
|
|
||||||
|
private fun cacheOrResolve(
|
||||||
|
context: Context, libName: String, destName: String,
|
||||||
|
cache: () -> String?, setCache: (String?) -> Unit
|
||||||
|
): String? {
|
||||||
|
val cached = cache()
|
||||||
|
if (cached != null) return cached
|
||||||
|
val resolved = resolve(context, libName, destName)
|
||||||
|
setCache(resolved)
|
||||||
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
fun tarPath(context: Context): String? = resolve(context, "libtar_bin.so", "tar_bin", cacheTar)
|
private fun resolve(context: Context, libName: String, destName: String): String? {
|
||||||
fun zstdPath(context: Context): String? = resolve(context, "libzstd_bin.so", "zstd_bin", cacheZstd)
|
|
||||||
|
|
||||||
private fun resolve(context: Context, libName: String, destName: String, cache: ResolveCache): String? {
|
|
||||||
if (cache.initialized) return cache.path
|
|
||||||
val nativeLibDir = context.applicationInfo.nativeLibraryDir
|
val nativeLibDir = context.applicationInfo.nativeLibraryDir
|
||||||
val source = File(nativeLibDir, libName)
|
val source = File(nativeLibDir, libName)
|
||||||
if (!source.isFile) {
|
if (!source.isFile) {
|
||||||
Log.e(TAG, "$libName NOT FOUND at ${source.absolutePath}")
|
Log.e(TAG, "$libName NOT FOUND at ${source.absolutePath}")
|
||||||
cache.initialized = true
|
|
||||||
cache.path = null
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val dest = File(context.filesDir, "bin/$destName")
|
val dest = File(context.filesDir, "bin/$destName")
|
||||||
@@ -40,10 +43,7 @@ object BinaryResolver {
|
|||||||
source.inputStream().use { src -> dest.outputStream().use { out -> src.copyTo(out) } }
|
source.inputStream().use { src -> dest.outputStream().use { out -> src.copyTo(out) } }
|
||||||
dest.setExecutable(true)
|
dest.setExecutable(true)
|
||||||
}
|
}
|
||||||
val result = dest.absolutePath
|
Log.i(TAG, "ready: $libName -> ${dest.absolutePath} (${dest.length()} bytes) canExec=${dest.canExecute()}")
|
||||||
Log.i(TAG, "ready: $libName -> $result (${dest.length()} bytes) canExec=${dest.canExecute()}")
|
return dest.absolutePath
|
||||||
cache.path = result
|
|
||||||
cache.initialized = true
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +23,11 @@ import java.io.File
|
|||||||
*/
|
*/
|
||||||
class RemoteSyncManager {
|
class RemoteSyncManager {
|
||||||
|
|
||||||
|
private sealed interface SyncEvent {
|
||||||
|
data class Phase(val progress: RemoteTransport.TransferProgress) : SyncEvent
|
||||||
|
data class Bytes(val progress: RemoteTransport.ByteProgress) : SyncEvent
|
||||||
|
}
|
||||||
|
|
||||||
private val TAG = "ResticWrapper"
|
private val TAG = "ResticWrapper"
|
||||||
|
|
||||||
/** Local temp directory used as restic repo for SMB/WebDAV backends. */
|
/** Local temp directory used as restic repo for SMB/WebDAV backends. */
|
||||||
@@ -42,9 +51,9 @@ class RemoteSyncManager {
|
|||||||
private fun ensureTransport(
|
private fun ensureTransport(
|
||||||
backend: String, url: String, user: String, pass: String, share: String, repoPath: String
|
backend: String, url: String, user: String, pass: String, share: String, repoPath: String
|
||||||
): RemoteTransport? = synchronized(transportLock) {
|
): RemoteTransport? = synchronized(transportLock) {
|
||||||
val key = "$backend|$url|$user|$pass|$share|$backendDomain|$repoPath"
|
val key = "$backend|$url|$user|${pass.hashCode()}|$share|$backendDomain|$repoPath"
|
||||||
if (key != transportConfigKey || transport == null) {
|
if (key != transportConfigKey || transport == null) {
|
||||||
transport?.let { Log.i(TAG, "transport config changed ($transportConfigKey -> $key), recreating") }
|
transport?.let { Log.i(TAG, "transport config changed, recreating") }
|
||||||
// Clear local temp repo when backend config changes so
|
// Clear local temp repo when backend config changes so
|
||||||
// syncFromRemote downloads fresh data from the new backend
|
// syncFromRemote downloads fresh data from the new backend
|
||||||
if (transportConfigKey.isNotEmpty() && tempRepoDir.isNotEmpty()) {
|
if (transportConfigKey.isNotEmpty() && tempRepoDir.isNotEmpty()) {
|
||||||
@@ -122,20 +131,41 @@ class RemoteSyncManager {
|
|||||||
needsUpload: Boolean,
|
needsUpload: Boolean,
|
||||||
onProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
action: suspend () -> Result<T>
|
action: suspend () -> AppResult<T>
|
||||||
): Result<T> {
|
): AppResult<T> {
|
||||||
if (backend != "smb" && backend != "webdav") return action()
|
if (backend != "smb" && backend != "webdav") return action()
|
||||||
|
|
||||||
return repoSyncMutex.withLock {
|
return repoSyncMutex.withLock {
|
||||||
|
coroutineScope {
|
||||||
var shouldCleanup = false
|
var shouldCleanup = false
|
||||||
|
var lastByteEmitMs = 0L
|
||||||
|
val progressChannel = Channel<SyncEvent>(CONFLATED)
|
||||||
|
val progressJob = launch(Dispatchers.Main) {
|
||||||
|
for (event in progressChannel) {
|
||||||
|
when (event) {
|
||||||
|
is SyncEvent.Phase -> onProgress(event.progress)
|
||||||
|
is SyncEvent.Bytes -> {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastByteEmitMs >= 50) {
|
||||||
|
onByteProgress(event.progress)
|
||||||
|
lastByteEmitMs = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val t = ensureTransport(backend, backendUrl, backendUser, backendPass, backendShare, repoPath)
|
val t = ensureTransport(backend, backendUrl, backendUser, backendPass, backendShare, repoPath)
|
||||||
?: return@withLock Result.failure(Exception("Failed to create transport for backend: $backend"))
|
?: return@coroutineScope err(AppError.Remote("Failed to create transport for backend: $backend", "connecting"))
|
||||||
|
|
||||||
val localDir = File(tempRepoDir)
|
val localDir = File(tempRepoDir)
|
||||||
|
|
||||||
val emitProgress: suspend (RemoteTransport.TransferProgress) -> Unit = { p ->
|
val emitProgress: suspend (RemoteTransport.TransferProgress) -> Unit = { p ->
|
||||||
withContext(Dispatchers.Main) { onProgress(p) }
|
progressChannel.send(SyncEvent.Phase(p))
|
||||||
|
}
|
||||||
|
val emitByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit = { p ->
|
||||||
|
progressChannel.send(SyncEvent.Bytes(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write ops always download to avoid overwriting remote changes.
|
// Write ops always download to avoid overwriting remote changes.
|
||||||
@@ -143,13 +173,11 @@ class RemoteSyncManager {
|
|||||||
val actualDownload = needsDownload && (needsUpload || !isLocalRepoPopulated())
|
val actualDownload = needsDownload && (needsUpload || !isLocalRepoPopulated())
|
||||||
if (actualDownload) {
|
if (actualDownload) {
|
||||||
Log.i(TAG, "syncFromRemote start: $repoPath -> $tempRepoDir")
|
Log.i(TAG, "syncFromRemote start: $repoPath -> $tempRepoDir")
|
||||||
val syncResult = RemoteTransport.syncFromRemote(t, localDir, repoPath, emitProgress, onByteProgress)
|
val syncResult = RemoteTransport.syncFromRemote(t, localDir, repoPath, emitProgress, emitByteProgress)
|
||||||
if (syncResult.isFailure) {
|
if (syncResult.isFailure) {
|
||||||
shouldCleanup = true
|
shouldCleanup = true
|
||||||
Log.e(TAG, "syncFromRemote FAILED: ${syncResult.exceptionOrNull()?.message}")
|
Log.e(TAG, "syncFromRemote FAILED: ${syncResult.exceptionOrNull()?.message}")
|
||||||
return@withLock Result.failure(
|
return@coroutineScope err(AppError.Remote("syncFromRemote failed: ${syncResult.exceptionOrNull()?.message}", "download"))
|
||||||
Exception("syncFromRemote failed: ${syncResult.exceptionOrNull()?.message}")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Log.i(TAG, "syncFromRemote complete")
|
Log.i(TAG, "syncFromRemote complete")
|
||||||
} else if (needsDownload) {
|
} else if (needsDownload) {
|
||||||
@@ -160,13 +188,11 @@ class RemoteSyncManager {
|
|||||||
|
|
||||||
if (needsUpload && result.isSuccess) {
|
if (needsUpload && result.isSuccess) {
|
||||||
Log.i(TAG, "syncToRemote start: $tempRepoDir -> $repoPath")
|
Log.i(TAG, "syncToRemote start: $tempRepoDir -> $repoPath")
|
||||||
val uploadResult = RemoteTransport.syncToRemote(t, localDir, repoPath, emitProgress, onByteProgress)
|
val uploadResult = RemoteTransport.syncToRemote(t, localDir, repoPath, emitProgress, emitByteProgress)
|
||||||
if (uploadResult.isFailure) {
|
if (uploadResult.isFailure) {
|
||||||
shouldCleanup = false // PRESERVE local repo — snapshot would be lost
|
shouldCleanup = false // PRESERVE local repo — snapshot would be lost
|
||||||
Log.e(TAG, "syncToRemote FAILED: ${uploadResult.exceptionOrNull()?.message} — local repo preserved for retry")
|
Log.e(TAG, "syncToRemote FAILED: ${uploadResult.exceptionOrNull()?.message} — local repo preserved for retry")
|
||||||
return@withLock Result.failure(
|
return@coroutineScope err(AppError.Remote("syncToRemote failed: ${uploadResult.exceptionOrNull()?.message}", "upload"))
|
||||||
Exception("syncToRemote failed: ${uploadResult.exceptionOrNull()?.message}")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Log.i(TAG, "syncToRemote complete")
|
Log.i(TAG, "syncToRemote complete")
|
||||||
shouldCleanup = true
|
shouldCleanup = true
|
||||||
@@ -180,8 +206,10 @@ class RemoteSyncManager {
|
|||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
shouldCleanup = true
|
shouldCleanup = true
|
||||||
Result.failure(e)
|
err(AppError.Remote(e.message ?: "Unknown error", "sync", cause = e))
|
||||||
} finally {
|
} finally {
|
||||||
|
progressChannel.close()
|
||||||
|
progressJob.join()
|
||||||
if (shouldCleanup) {
|
if (shouldCleanup) {
|
||||||
Log.i(TAG, "withRemoteSync: cleaning up temp dirs")
|
Log.i(TAG, "withRemoteSync: cleaning up temp dirs")
|
||||||
cleanupTempDirs()
|
cleanupTempDirs()
|
||||||
@@ -191,6 +219,7 @@ class RemoteSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public safety-net cleanup called by fragment lifecycle.
|
* Public safety-net cleanup called by fragment lifecycle.
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import android.util.Log
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/** Thrown by transports when a remote directory genuinely does not exist (HTTP 404). */
|
|
||||||
class FileNotFoundException(path: String) : Exception("Directory not found: $path")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified abstraction for remote file transport (SMB / WebDAV).
|
* Unified abstraction for remote file transport (SMB / WebDAV).
|
||||||
@@ -38,17 +37,17 @@ interface RemoteTransport {
|
|||||||
val currentFile: String
|
val currentFile: String
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {}): Result<Unit>
|
suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {}): AppResult<Unit>
|
||||||
suspend fun download(remotePath: String, localPath: String, onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {}): Result<Unit>
|
suspend fun download(remotePath: String, localPath: String, onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {}): AppResult<Unit>
|
||||||
|
|
||||||
/** List entries in a remote directory (files and subdirectories). */
|
/** List entries in a remote directory (files and subdirectories). */
|
||||||
suspend fun listFiles(remoteDir: String): Result<List<RemoteFileInfo>>
|
suspend fun listFiles(remoteDir: String): AppResult<List<RemoteFileInfo>>
|
||||||
|
|
||||||
/** Create a directory and any missing parents on the remote. */
|
/** Create a directory and any missing parents on the remote. */
|
||||||
suspend fun mkdirs(remotePath: String): Result<Unit>
|
suspend fun mkdirs(remotePath: String): AppResult<Unit>
|
||||||
|
|
||||||
suspend fun delete(remotePath: String): Result<Unit>
|
suspend fun delete(remotePath: String): AppResult<Unit>
|
||||||
suspend fun exists(remotePath: String): Result<Boolean>
|
suspend fun exists(remotePath: String): AppResult<Boolean>
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "RemoteTransport"
|
private const val TAG = "RemoteTransport"
|
||||||
@@ -79,9 +78,9 @@ interface RemoteTransport {
|
|||||||
*/
|
*/
|
||||||
private suspend fun <T> withRetry(
|
private suspend fun <T> withRetry(
|
||||||
tag: String,
|
tag: String,
|
||||||
block: suspend () -> Result<T>
|
block: suspend () -> AppResult<T>
|
||||||
): Result<T> {
|
): AppResult<T> {
|
||||||
var lastError: Result<T>? = null
|
var lastError: AppResult<T>? = null
|
||||||
for (attempt in 0..MAX_RETRIES) {
|
for (attempt in 0..MAX_RETRIES) {
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
val waitMs = 1000L * (1 shl (attempt - 1)) // 1s, 2s, 4s
|
val waitMs = 1000L * (1 shl (attempt - 1)) // 1s, 2s, 4s
|
||||||
@@ -97,7 +96,7 @@ interface RemoteTransport {
|
|||||||
}
|
}
|
||||||
return result // permanent error — don't retry
|
return result // permanent error — don't retry
|
||||||
}
|
}
|
||||||
return lastError ?: Result.failure(Exception("$tag: max retries exceeded"))
|
return lastError ?: err(AppError.Remote("$tag: max retries exceeded", "retry"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun create(
|
fun create(
|
||||||
@@ -133,7 +132,7 @@ interface RemoteTransport {
|
|||||||
remoteDir: String,
|
remoteDir: String,
|
||||||
onProgress: suspend (TransferProgress) -> Unit = {},
|
onProgress: suspend (TransferProgress) -> Unit = {},
|
||||||
onByteProgress: suspend (ByteProgress) -> Unit = {}
|
onByteProgress: suspend (ByteProgress) -> Unit = {}
|
||||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
): AppResult<Unit> = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
localDir.mkdirs()
|
localDir.mkdirs()
|
||||||
val remoteFiles = listRemoteRecursive(transport, remoteDir)
|
val remoteFiles = listRemoteRecursive(transport, remoteDir)
|
||||||
@@ -141,7 +140,7 @@ interface RemoteTransport {
|
|||||||
// This is normal for first-time init where the repo doesn't exist yet.
|
// This is normal for first-time init where the repo doesn't exist yet.
|
||||||
if (remoteFiles == null) {
|
if (remoteFiles == null) {
|
||||||
Log.w(TAG, "syncFromRemote: remote dir '$remoteDir' not accessible, treating as empty")
|
Log.w(TAG, "syncFromRemote: remote dir '$remoteDir' not accessible, treating as empty")
|
||||||
return@withContext Result.success(Unit)
|
return@withContext AppResult.Success(Unit)
|
||||||
}
|
}
|
||||||
onProgress(TransferProgress("list", 0, remoteFiles.size))
|
onProgress(TransferProgress("list", 0, remoteFiles.size))
|
||||||
val remoteByPath = remoteFiles.associateBy { it.path }
|
val remoteByPath = remoteFiles.associateBy { it.path }
|
||||||
@@ -174,9 +173,7 @@ interface RemoteTransport {
|
|||||||
// If any download failed, abort before deleting local files —
|
// If any download failed, abort before deleting local files —
|
||||||
// deleting would destroy valid data for an incomplete sync.
|
// deleting would destroy valid data for an incomplete sync.
|
||||||
if (errors.isNotEmpty()) {
|
if (errors.isNotEmpty()) {
|
||||||
return@withContext Result.failure(
|
return@withContext err(AppError.Remote("syncFromRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}", "sync"))
|
||||||
Exception("syncFromRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete local files not on remote (e.g. after prune on another client)
|
// Delete local files not on remote (e.g. after prune on another client)
|
||||||
@@ -210,7 +207,7 @@ interface RemoteTransport {
|
|||||||
remoteDir: String,
|
remoteDir: String,
|
||||||
onProgress: suspend (TransferProgress) -> Unit = {},
|
onProgress: suspend (TransferProgress) -> Unit = {},
|
||||||
onByteProgress: suspend (ByteProgress) -> Unit = {}
|
onByteProgress: suspend (ByteProgress) -> Unit = {}
|
||||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
): AppResult<Unit> = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val localFiles = walkLocalFiles(localDir)
|
val localFiles = walkLocalFiles(localDir)
|
||||||
onProgress(TransferProgress("list", 0, localFiles.size))
|
onProgress(TransferProgress("list", 0, localFiles.size))
|
||||||
@@ -261,9 +258,7 @@ interface RemoteTransport {
|
|||||||
// If any upload failed, abort before deleting remote files —
|
// If any upload failed, abort before deleting remote files —
|
||||||
// deleting during failed sync could lose the only copy on remote.
|
// deleting during failed sync could lose the only copy on remote.
|
||||||
if (errors.isNotEmpty()) {
|
if (errors.isNotEmpty()) {
|
||||||
return@withContext Result.failure(
|
return@withContext err(AppError.Remote("syncToRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}", "sync"))
|
||||||
Exception("syncToRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete remote files no longer present locally
|
// Delete remote files no longer present locally
|
||||||
@@ -275,9 +270,11 @@ interface RemoteTransport {
|
|||||||
transport.delete("$remoteDir/$relPath")
|
transport.delete("$remoteDir/$relPath")
|
||||||
}
|
}
|
||||||
onProgress(TransferProgress("complete", uploaded, syncTotal, "已传输: $uploaded 跳过: $uploadSkipped"))
|
onProgress(TransferProgress("complete", uploaded, syncTotal, "已传输: $uploaded 跳过: $uploadSkipped"))
|
||||||
Result.success(Unit)
|
AppResult.Success(Unit)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(Exception("syncToRemote failed: ${e.message}", e))
|
err(AppError.Remote("syncToRemote failed: ${e.message}", "sync", cause = e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,10 +307,10 @@ interface RemoteTransport {
|
|||||||
transport.listFiles(fullDir)
|
transport.listFiles(fullDir)
|
||||||
}
|
}
|
||||||
if (listResult.isFailure) {
|
if (listResult.isFailure) {
|
||||||
val err = listResult.exceptionOrNull()
|
val err = listResult.errorOrNull()
|
||||||
// 404 on a subdirectory: directory doesn't exist, skip it silently.
|
// 404 on a subdirectory: directory doesn't exist, skip it silently.
|
||||||
// 404 on the root directory: fatal — the remote repo path may be wrong.
|
// 404 on the root directory: fatal — the remote repo path may be wrong.
|
||||||
if (err is FileNotFoundException) {
|
if (err?.isFileNotFound() == true) {
|
||||||
if (subDir.isEmpty()) {
|
if (subDir.isEmpty()) {
|
||||||
Log.e(TAG, "listRemoteRecursive: root dir '$fullDir' returned 404 — repo may not exist or is rate-limited")
|
Log.e(TAG, "listRemoteRecursive: root dir '$fullDir' returned 404 — repo may not exist or is rate-limited")
|
||||||
return null
|
return null
|
||||||
@@ -367,3 +364,7 @@ interface RemoteTransport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extension to check if an [AppError] represents a "not found" remote error. */
|
||||||
|
private fun AppError.isFileNotFound(): Boolean =
|
||||||
|
this is AppError.Remote && this.isNotFound
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import kotlinx.coroutines.isActive
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlin.coroutines.coroutineContext
|
import kotlin.coroutines.coroutineContext
|
||||||
|
import com.example.androidbackupgui.backup.AppError
|
||||||
|
import com.example.androidbackupgui.backup.AppResult
|
||||||
|
import com.example.androidbackupgui.backup.err
|
||||||
|
|
||||||
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
|
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
|
||||||
private val resticJson = Json { ignoreUnknownKeys = true }
|
private val resticJson = Json { ignoreUnknownKeys = true }
|
||||||
@@ -21,7 +24,7 @@ class ResticBackup(
|
|||||||
private val envResolver: ResticEnvResolver,
|
private val envResolver: ResticEnvResolver,
|
||||||
private val syncManager: RemoteSyncManager
|
private val syncManager: RemoteSyncManager
|
||||||
) {
|
) {
|
||||||
private val TAG = "ResticWrapper"
|
private val TAG = "ResticBackup"
|
||||||
|
|
||||||
// ── Backup ─────────────────────────────────────────
|
// ── Backup ─────────────────────────────────────────
|
||||||
|
|
||||||
@@ -39,7 +42,7 @@ class ResticBackup(
|
|||||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
|
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
|
||||||
): Result<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
|
): AppResult<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
|
||||||
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||||
needsDownload = true, needsUpload = true,
|
needsDownload = true, needsUpload = true,
|
||||||
@@ -61,7 +64,7 @@ class ResticBackup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.exitCode != 0) {
|
if (result.exitCode != 0) {
|
||||||
return@withRemoteSync Result.failure(Exception("restic backup failed: ${result.stderr}"))
|
return@withRemoteSync err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
|
||||||
}
|
}
|
||||||
|
|
||||||
parseBackupSummary(result.stdout)
|
parseBackupSummary(result.stdout)
|
||||||
@@ -71,16 +74,16 @@ class ResticBackup(
|
|||||||
// ── Internal helpers ───────────────────────────────
|
// ── Internal helpers ───────────────────────────────
|
||||||
|
|
||||||
/** Parse the JSON summary from the end of restic backup output. */
|
/** Parse the JSON summary from the end of restic backup output. */
|
||||||
private fun parseBackupSummary(stdout: String): Result<ResticWrapper.BackupSummary> {
|
private fun parseBackupSummary(stdout: String): AppResult<ResticWrapper.BackupSummary> {
|
||||||
val lines = stdout.lines()
|
val lines = stdout.lines()
|
||||||
for (i in lines.indices.reversed()) {
|
for (i in lines.indices.reversed()) {
|
||||||
val line = lines[i].trim()
|
val line = lines[i].trim()
|
||||||
if (!line.startsWith("{")) continue
|
if (!line.startsWith("{")) continue
|
||||||
try {
|
try {
|
||||||
val summary = resticJson.decodeFromString<ResticWrapper.BackupSummary>(line)
|
val summary = resticJson.decodeFromString<ResticWrapper.BackupSummary>(line)
|
||||||
if (summary.messageType == "summary" && summary.snapshotId.isNotEmpty()) return Result.success(summary)
|
if (summary.messageType == "summary" && summary.snapshotId.isNotEmpty()) return AppResult.Success(summary)
|
||||||
} catch (_: Exception) { /* keep looking */ }
|
} catch (_: Exception) { /* keep looking */ }
|
||||||
}
|
}
|
||||||
return Result.failure(Exception("No summary found in restic output"))
|
return err(AppError.Parse("restic 备份输出未找到摘要信息", "stdout=" + stdout.length))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,5 +41,5 @@ object ResticBinary {
|
|||||||
return dir.absolutePath
|
return dir.absolutePath
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isReady(): Boolean = false // call prepare() instead
|
fun isReady(): Boolean = cachedBinaryPath != null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.example.androidbackupgui.backup
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
|
import com.example.androidbackupgui.backup.AppError
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -28,10 +29,9 @@ class ResticCommandRunner {
|
|||||||
)
|
)
|
||||||
|
|
||||||
/** Build the full command list to run restic. */
|
/** Build the full command list to run restic. */
|
||||||
fun buildCommandArgs(args: List<String>): List<String> {
|
fun buildCommandArgs(args: List<String>): List<String> =
|
||||||
val cmd = listOf(binaryPath) + args
|
(listOf(binaryPath) + args).also { cmd ->
|
||||||
Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args → cmd=$cmd")
|
Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args -> cmd=$cmd")
|
||||||
return cmd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run restic (non-streaming). */
|
/** Run restic (non-streaming). */
|
||||||
@@ -39,6 +39,8 @@ class ResticCommandRunner {
|
|||||||
val cmdArgs = buildCommandArgs(args)
|
val cmdArgs = buildCommandArgs(args)
|
||||||
Log.i(TAG, "runRestic cmd=${cmdArgs.joinToString(" ")}")
|
Log.i(TAG, "runRestic cmd=${cmdArgs.joinToString(" ")}")
|
||||||
Log.d(TAG, "runRestic REPOSITORY=${env["RESTIC_REPOSITORY"]}")
|
Log.d(TAG, "runRestic REPOSITORY=${env["RESTIC_REPOSITORY"]}")
|
||||||
|
// NOTE: Do NOT log RESTIC_PASSWORD or any value derived from it.
|
||||||
|
// RESTIC_REPOSITORY is safe to log (does not contain secrets).
|
||||||
env["TMPDIR"]?.let { File(it).mkdirs() }
|
env["TMPDIR"]?.let { File(it).mkdirs() }
|
||||||
return try {
|
return try {
|
||||||
val pb = ProcessBuilder(cmdArgs)
|
val pb = ProcessBuilder(cmdArgs)
|
||||||
@@ -46,25 +48,15 @@ class ResticCommandRunner {
|
|||||||
pb.redirectErrorStream(false)
|
pb.redirectErrorStream(false)
|
||||||
val process = pb.start()
|
val process = pb.start()
|
||||||
|
|
||||||
val stderrText = StringBuilder()
|
|
||||||
val stderrThread = Thread({
|
|
||||||
try {
|
|
||||||
process.errorStream.bufferedReader().use { reader ->
|
|
||||||
var line: String?
|
|
||||||
while (reader.readLine().also { line = it } != null) {
|
|
||||||
Log.d(TAG, "restic stderr: $line")
|
|
||||||
stderrText.appendLine(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}, "restic-stderr").apply { isDaemon = true; start() }
|
|
||||||
|
|
||||||
val stdout = process.inputStream.bufferedReader().use(BufferedReader::readText)
|
val stdout = process.inputStream.bufferedReader().use(BufferedReader::readText)
|
||||||
val exitCode = process.waitFor()
|
val exitCode = process.waitFor()
|
||||||
stderrThread.join(5000)
|
val stderrBytes = process.errorStream.readAllBytes()
|
||||||
|
val stderrText = stderrBytes.decodeToString()
|
||||||
Log.i(TAG, "runRestic exitCode=$exitCode stdout_len=${stdout.length}")
|
Log.i(TAG, "runRestic exitCode=$exitCode stdout_len=${stdout.length}")
|
||||||
if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText}")
|
if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText.trim()}")
|
||||||
CommandResult(stdout.trim(), stderrText.toString().trim(), exitCode)
|
CommandResult(stdout.trim(), stderrText.trim(), exitCode)
|
||||||
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "runRestic exception", e)
|
Log.e(TAG, "runRestic exception", e)
|
||||||
CommandResult("", e.message ?: "Unknown error", -1)
|
CommandResult("", e.message ?: "Unknown error", -1)
|
||||||
@@ -136,7 +128,9 @@ class ResticCommandRunner {
|
|||||||
|
|
||||||
Log.i(TAG, "runResticStreaming exitCode=$exitCode stdout_len=${stdoutText.length}")
|
Log.i(TAG, "runResticStreaming exitCode=$exitCode stdout_len=${stdoutText.length}")
|
||||||
if (stderrText.isNotEmpty()) Log.w(TAG, "runResticStreaming stderr: ${stderrText}")
|
if (stderrText.isNotEmpty()) Log.w(TAG, "runResticStreaming stderr: ${stderrText}")
|
||||||
CommandResult(stdoutText.toString().trim(), stderrText.toString().trim(), exitCode)
|
CommandResult(stdoutText.toString().trim(), stderrText.trim(), exitCode)
|
||||||
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "runResticStreaming exception", e)
|
Log.e(TAG, "runResticStreaming exception", e)
|
||||||
try { process?.destroy() } catch (_: Exception) {}
|
try { process?.destroy() } catch (_: Exception) {}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package com.example.androidbackupgui.backup
|
|||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import com.example.androidbackupgui.backup.AppError
|
||||||
|
import com.example.androidbackupgui.backup.AppResult
|
||||||
|
import com.example.androidbackupgui.backup.err
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository maintenance operations: prune, check, stats.
|
* Repository maintenance operations: prune, check, stats.
|
||||||
@@ -28,7 +31,7 @@ class ResticMaintenance(
|
|||||||
backendShare: String = "",
|
backendShare: String = "",
|
||||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
): Result<String> =
|
): AppResult<String> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||||
needsDownload = true, needsUpload = true,
|
needsDownload = true, needsUpload = true,
|
||||||
@@ -37,8 +40,8 @@ class ResticMaintenance(
|
|||||||
) {
|
) {
|
||||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||||
val result = runner.runRestic(env, "prune")
|
val result = runner.runRestic(env, "prune")
|
||||||
if (result.exitCode == 0) Result.success(result.stdout)
|
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||||
else Result.failure(Exception("restic prune failed: ${result.stderr}"))
|
else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +57,7 @@ class ResticMaintenance(
|
|||||||
backendShare: String = "",
|
backendShare: String = "",
|
||||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
): Result<String> =
|
): AppResult<String> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||||
needsDownload = true, needsUpload = false,
|
needsDownload = true, needsUpload = false,
|
||||||
@@ -63,8 +66,8 @@ class ResticMaintenance(
|
|||||||
) {
|
) {
|
||||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||||
val result = runner.runRestic(env, "check")
|
val result = runner.runRestic(env, "check")
|
||||||
if (result.exitCode == 0) Result.success(result.stdout)
|
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||||
else Result.failure(Exception("restic check failed: ${result.stderr}"))
|
else err(AppError.Restic("restic check 失败", result.exitCode, result.stderr))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +83,7 @@ class ResticMaintenance(
|
|||||||
backendShare: String = "",
|
backendShare: String = "",
|
||||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
): Result<String> =
|
): AppResult<String> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||||
needsDownload = true, needsUpload = false,
|
needsDownload = true, needsUpload = false,
|
||||||
@@ -89,8 +92,8 @@ class ResticMaintenance(
|
|||||||
) {
|
) {
|
||||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||||
val result = runner.runRestic(env, "stats")
|
val result = runner.runRestic(env, "stats")
|
||||||
if (result.exitCode == 0) Result.success(result.stdout)
|
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||||
else Result.failure(Exception("restic stats failed: ${result.stderr}"))
|
else err(AppError.Restic("restic stats 失败", result.exitCode, result.stderr))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package com.example.androidbackupgui.backup
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import com.example.androidbackupgui.backup.AppError
|
||||||
|
import com.example.androidbackupgui.backup.AppResult
|
||||||
|
import com.example.androidbackupgui.backup.err
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository lifecycle operations: init and repo URL construction.
|
* Repository lifecycle operations: init and repo URL construction.
|
||||||
@@ -29,7 +32,7 @@ class ResticRepoInit(
|
|||||||
backendShare: String = "",
|
backendShare: String = "",
|
||||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
): Result<Unit> =
|
): AppResult<Unit> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||||
needsDownload = true, needsUpload = true,
|
needsDownload = true, needsUpload = true,
|
||||||
@@ -40,7 +43,7 @@ class ResticRepoInit(
|
|||||||
val result = runner.runRestic(env, "init")
|
val result = runner.runRestic(env, "init")
|
||||||
// exitCode 0 = brand new repo created, needs upload
|
// exitCode 0 = brand new repo created, needs upload
|
||||||
if (result.exitCode == 0) {
|
if (result.exitCode == 0) {
|
||||||
return@withRemoteSync Result.success(Unit)
|
return@withRemoteSync AppResult.Success(Unit)
|
||||||
}
|
}
|
||||||
// exitCode 1 = config already exists; verify the repo is actually usable
|
// exitCode 1 = config already exists; verify the repo is actually usable
|
||||||
if (result.exitCode == 1) {
|
if (result.exitCode == 1) {
|
||||||
@@ -48,14 +51,14 @@ class ResticRepoInit(
|
|||||||
if (verify.exitCode == 0) {
|
if (verify.exitCode == 0) {
|
||||||
// Repo is healthy — already initialized with matching password
|
// Repo is healthy — already initialized with matching password
|
||||||
Log.i(TAG, "init: repo already initialized and verified")
|
Log.i(TAG, "init: repo already initialized and verified")
|
||||||
return@withRemoteSync Result.success(Unit)
|
return@withRemoteSync AppResult.Success(Unit)
|
||||||
}
|
}
|
||||||
// Config exists but repo is corrupted (wrong password, missing keys, etc.)
|
// Config exists but repo is corrupted (wrong password, missing keys, etc.)
|
||||||
return@withRemoteSync Result.failure(
|
return@withRemoteSync err(
|
||||||
Exception("仓库已存在但无法验证: ${verify.stderr.ifEmpty { "密码错误或密钥缺失" }}。请删除远端仓库后重试。")
|
AppError.Restic("仓库已存在但无法验证", verify.exitCode, verify.stderr)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Result.failure(Exception("restic init failed: ${result.stderr}"))
|
err(AppError.Restic("restic init 失败", result.exitCode, result.stderr))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import kotlinx.coroutines.withContext
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlin.coroutines.coroutineContext
|
import kotlin.coroutines.coroutineContext
|
||||||
|
import com.example.androidbackupgui.backup.AppError
|
||||||
|
import com.example.androidbackupgui.backup.AppResult
|
||||||
|
import com.example.androidbackupgui.backup.err
|
||||||
|
|
||||||
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
|
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
|
||||||
private val resticJson = Json { ignoreUnknownKeys = true }
|
private val resticJson = Json { ignoreUnknownKeys = true }
|
||||||
@@ -38,7 +41,7 @@ class ResticRestore(
|
|||||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
onProgress: suspend (String) -> Unit = {}
|
onProgress: suspend (String) -> Unit = {}
|
||||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
): AppResult<Unit> = withContext(Dispatchers.IO) {
|
||||||
val emit: suspend (String) -> Unit = { s -> withContext(Dispatchers.Main) { onProgress(s) } }
|
val emit: suspend (String) -> Unit = { s -> withContext(Dispatchers.Main) { onProgress(s) } }
|
||||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||||
needsDownload = true, needsUpload = false,
|
needsDownload = true, needsUpload = false,
|
||||||
@@ -67,8 +70,8 @@ class ResticRestore(
|
|||||||
} catch (_: Exception) { emit(line) }
|
} catch (_: Exception) { emit(line) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.exitCode == 0) Result.success(Unit)
|
if (result.exitCode == 0) AppResult.Success(Unit)
|
||||||
else Result.failure(Exception("restic restore failed: ${result.stderr}"))
|
else err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +89,7 @@ class ResticRestore(
|
|||||||
backendShare: String = "",
|
backendShare: String = "",
|
||||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
): Result<String> = withContext(Dispatchers.IO) {
|
): AppResult<String> = withContext(Dispatchers.IO) {
|
||||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||||
needsDownload = true, needsUpload = false,
|
needsDownload = true, needsUpload = false,
|
||||||
onProgress = onSyncProgress,
|
onProgress = onSyncProgress,
|
||||||
@@ -94,8 +97,8 @@ class ResticRestore(
|
|||||||
) {
|
) {
|
||||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||||
val result = runner.runRestic(env, "dump", snapshotId, filePath)
|
val result = runner.runRestic(env, "dump", snapshotId, filePath)
|
||||||
if (result.exitCode == 0) Result.success(result.stdout)
|
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||||
else Result.failure(Exception(result.stderr.ifEmpty { "restic dump failed with exit code ${result.exitCode}" }))
|
else err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package com.example.androidbackupgui.backup
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import com.example.androidbackupgui.backup.AppError
|
||||||
|
import com.example.androidbackupgui.backup.AppResult
|
||||||
|
import com.example.androidbackupgui.backup.err
|
||||||
|
|
||||||
|
|
||||||
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
|
/** Shared Json instance configured for restic's snake_case output via @SerialName. */
|
||||||
@@ -33,7 +36,7 @@ class ResticSnapshotOps(
|
|||||||
backendShare: String = "",
|
backendShare: String = "",
|
||||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
): Result<List<ResticWrapper.ResticSnapshot>> = withContext(Dispatchers.IO) {
|
): AppResult<List<ResticWrapper.ResticSnapshot>> = withContext(Dispatchers.IO) {
|
||||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||||
needsDownload = true, needsUpload = false,
|
needsDownload = true, needsUpload = false,
|
||||||
onProgress = onSyncProgress,
|
onProgress = onSyncProgress,
|
||||||
@@ -46,16 +49,16 @@ class ResticSnapshotOps(
|
|||||||
val result = runner.runRestic(env, args)
|
val result = runner.runRestic(env, args)
|
||||||
|
|
||||||
if (result.exitCode != 0) {
|
if (result.exitCode != 0) {
|
||||||
return@withRemoteSync Result.failure(Exception("restic snapshots failed: ${result.stderr}"))
|
return@withRemoteSync err(AppError.Restic("restic snapshots 失败", result.exitCode, result.stderr))
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val snapshots = resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
|
val snapshots = resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
|
||||||
result.stdout.ifEmpty { "[]" }
|
result.stdout.ifEmpty { "[]" }
|
||||||
)
|
)
|
||||||
Result.success(snapshots.sortedByDescending { it.time })
|
AppResult.Success(snapshots.sortedByDescending { it.time })
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(Exception("Failed to parse snapshot JSON: ${e.message}"))
|
err(AppError.Parse("解析快照 JSON 失败", e.message ?: ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,7 +79,7 @@ class ResticSnapshotOps(
|
|||||||
backendShare: String = "",
|
backendShare: String = "",
|
||||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
): Result<String> = withContext(Dispatchers.IO) {
|
): AppResult<String> = withContext(Dispatchers.IO) {
|
||||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||||
needsDownload = true, needsUpload = true,
|
needsDownload = true, needsUpload = true,
|
||||||
onProgress = onSyncProgress,
|
onProgress = onSyncProgress,
|
||||||
@@ -93,8 +96,8 @@ class ResticSnapshotOps(
|
|||||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||||
val result = runner.runRestic(env, args)
|
val result = runner.runRestic(env, args)
|
||||||
|
|
||||||
if (result.exitCode == 0) Result.success(result.stdout)
|
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||||
else Result.failure(Exception("restic forget failed: ${result.stderr}"))
|
else err(AppError.Restic("restic forget 失败", result.exitCode, result.stderr))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import kotlinx.coroutines.withContext
|
|||||||
import kotlin.coroutines.coroutineContext
|
import kotlin.coroutines.coroutineContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
|
import com.example.androidbackupgui.backup.AppError
|
||||||
|
import com.example.androidbackupgui.backup.AppResult
|
||||||
|
import com.example.androidbackupgui.backup.err
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps the restic CLI binary for backup/restore operations.
|
* Wraps the restic CLI binary for backup/restore operations.
|
||||||
@@ -93,7 +96,7 @@ object ResticWrapper {
|
|||||||
backendShare: String = "",
|
backendShare: String = "",
|
||||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
): Result<Unit> = repoInit.init(
|
): AppResult<Unit> = repoInit.init(
|
||||||
repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare,
|
repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare,
|
||||||
onSyncProgress, onByteSyncProgress
|
onSyncProgress, onByteSyncProgress
|
||||||
)
|
)
|
||||||
@@ -132,7 +135,7 @@ object ResticWrapper {
|
|||||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
onProgress: suspend (ResticProgress) -> Unit = {}
|
onProgress: suspend (ResticProgress) -> Unit = {}
|
||||||
): Result<BackupSummary> = backupOp.backup(
|
): AppResult<BackupSummary> = backupOp.backup(
|
||||||
repoPath, password, paths, tags, hostname,
|
repoPath, password, paths, tags, hostname,
|
||||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||||
onSyncProgress, onByteSyncProgress, onProgress
|
onSyncProgress, onByteSyncProgress, onProgress
|
||||||
@@ -154,7 +157,7 @@ object ResticWrapper {
|
|||||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
onProgress: suspend (String) -> Unit = {}
|
onProgress: suspend (String) -> Unit = {}
|
||||||
): Result<Unit> = restoreOp.restore(
|
): AppResult<Unit> = restoreOp.restore(
|
||||||
repoPath, password, snapshotId, targetPath, include,
|
repoPath, password, snapshotId, targetPath, include,
|
||||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||||
onSyncProgress, onByteSyncProgress, onProgress
|
onSyncProgress, onByteSyncProgress, onProgress
|
||||||
@@ -174,7 +177,7 @@ object ResticWrapper {
|
|||||||
backendShare: String = "",
|
backendShare: String = "",
|
||||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
): Result<String> = restoreOp.dump(
|
): AppResult<String> = restoreOp.dump(
|
||||||
repoPath, password, snapshotId, filePath,
|
repoPath, password, snapshotId, filePath,
|
||||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||||
onSyncProgress, onByteSyncProgress
|
onSyncProgress, onByteSyncProgress
|
||||||
@@ -193,7 +196,7 @@ object ResticWrapper {
|
|||||||
backendShare: String = "",
|
backendShare: String = "",
|
||||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
): Result<List<ResticSnapshot>> = snapshotOps.listSnapshots(
|
): AppResult<List<ResticSnapshot>> = snapshotOps.listSnapshots(
|
||||||
repoPath, password, tag,
|
repoPath, password, tag,
|
||||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||||
onSyncProgress, onByteSyncProgress
|
onSyncProgress, onByteSyncProgress
|
||||||
@@ -213,7 +216,7 @@ object ResticWrapper {
|
|||||||
backendShare: String = "",
|
backendShare: String = "",
|
||||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
): Result<String> = snapshotOps.forget(
|
): AppResult<String> = snapshotOps.forget(
|
||||||
repoPath, password, keepDaily, keepWeekly, keepMonthly, dryRun,
|
repoPath, password, keepDaily, keepWeekly, keepMonthly, dryRun,
|
||||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||||
onSyncProgress, onByteSyncProgress
|
onSyncProgress, onByteSyncProgress
|
||||||
@@ -231,7 +234,7 @@ object ResticWrapper {
|
|||||||
backendShare: String = "",
|
backendShare: String = "",
|
||||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
): Result<String> = maintenance.prune(
|
): AppResult<String> = maintenance.prune(
|
||||||
repoPath, password,
|
repoPath, password,
|
||||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||||
onSyncProgress, onByteSyncProgress
|
onSyncProgress, onByteSyncProgress
|
||||||
@@ -247,7 +250,7 @@ object ResticWrapper {
|
|||||||
backendShare: String = "",
|
backendShare: String = "",
|
||||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
): Result<String> = maintenance.check(
|
): AppResult<String> = maintenance.check(
|
||||||
repoPath, password,
|
repoPath, password,
|
||||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||||
onSyncProgress, onByteSyncProgress
|
onSyncProgress, onByteSyncProgress
|
||||||
@@ -263,7 +266,7 @@ object ResticWrapper {
|
|||||||
backendShare: String = "",
|
backendShare: String = "",
|
||||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||||
): Result<String> = maintenance.stats(
|
): AppResult<String> = maintenance.stats(
|
||||||
repoPath, password,
|
repoPath, password,
|
||||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||||
onSyncProgress, onByteSyncProgress
|
onSyncProgress, onByteSyncProgress
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.example.androidbackupgui.backup
|
package com.example.androidbackupgui.backup
|
||||||
import com.example.androidbackupgui.root.RootShell
|
import com.example.androidbackupgui.root.RootShell
|
||||||
import com.example.androidbackupgui.root.shellEscape
|
import com.example.androidbackupgui.root.shellEscape
|
||||||
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
@@ -42,6 +43,7 @@ object RestoreOperation {
|
|||||||
* @param filterPkgs if non-null, only restore packages in this set
|
* @param filterPkgs if non-null, only restore packages in this set
|
||||||
*/
|
*/
|
||||||
suspend fun restoreApps(
|
suspend fun restoreApps(
|
||||||
|
context: Context,
|
||||||
backupDir: File,
|
backupDir: File,
|
||||||
userId: String = "0",
|
userId: String = "0",
|
||||||
filterPkgs: Set<String>? = null,
|
filterPkgs: Set<String>? = null,
|
||||||
@@ -50,6 +52,11 @@ object RestoreOperation {
|
|||||||
val emit: suspend (RestoreProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
val emit: suspend (RestoreProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||||
val startTime = System.currentTimeMillis()
|
val startTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
// Resolve bundled binary paths for tar/zstd (backup used them, restore must too)
|
||||||
|
val tarCmd = BinaryResolver.tarPath(context) ?: "tar"
|
||||||
|
val bundledZstd = BinaryResolver.zstdPath(context)
|
||||||
|
val zstdCmd = bundledZstd ?: "zstd"
|
||||||
|
|
||||||
// Read app list from backup
|
// Read app list from backup
|
||||||
val appListFile = File(backupDir, "appList.txt")
|
val appListFile = File(backupDir, "appList.txt")
|
||||||
val allPackages = if (appListFile.exists()) {
|
val allPackages = if (appListFile.exists()) {
|
||||||
@@ -88,7 +95,7 @@ object RestoreOperation {
|
|||||||
|
|
||||||
// 1. Install APK
|
// 1. Install APK
|
||||||
emit(RestoreProgress(index + 1, packages.size, pkg, "install", "正在安装 APK…"))
|
emit(RestoreProgress(index + 1, packages.size, pkg, "install", "正在安装 APK…"))
|
||||||
val installed = installApk(appBackupDir)
|
val installed = installApk(pkg, appBackupDir)
|
||||||
|
|
||||||
if (!installed) {
|
if (!installed) {
|
||||||
failAtomic.incrementAndGet()
|
failAtomic.incrementAndGet()
|
||||||
@@ -101,11 +108,11 @@ object RestoreOperation {
|
|||||||
|
|
||||||
// 3. Restore data
|
// 3. Restore data
|
||||||
emit(RestoreProgress(index + 1, packages.size, pkg, "data", "正在恢复数据…"))
|
emit(RestoreProgress(index + 1, packages.size, pkg, "data", "正在恢复数据…"))
|
||||||
restoreData(appBackupDir)
|
restoreData(pkg, userId, appBackupDir, tarCmd, zstdCmd)
|
||||||
|
|
||||||
// 4. Restore OBB
|
// 4. Restore OBB
|
||||||
emit(RestoreProgress(index + 1, packages.size, pkg, "obb", "正在恢复 OBB…"))
|
emit(RestoreProgress(index + 1, packages.size, pkg, "obb", "正在恢复 OBB…"))
|
||||||
restoreObb(pkg, appBackupDir)
|
restoreObb(pkg, appBackupDir, tarCmd, zstdCmd)
|
||||||
|
|
||||||
// 5. Restore SSAID
|
// 5. Restore SSAID
|
||||||
emit(RestoreProgress(index + 1, packages.size, pkg, "ssaid", "正在恢复 SSAID…"))
|
emit(RestoreProgress(index + 1, packages.size, pkg, "ssaid", "正在恢复 SSAID…"))
|
||||||
@@ -132,7 +139,7 @@ object RestoreOperation {
|
|||||||
RestoreResult(successCount, failCount, elapsed)
|
RestoreResult(successCount, failCount, elapsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun installApk(appDir: File): Boolean {
|
private suspend fun installApk(packageName: String, appDir: File): Boolean {
|
||||||
// Find APK files
|
// Find APK files
|
||||||
val apkFiles = appDir.listFiles()
|
val apkFiles = appDir.listFiles()
|
||||||
?.filter { it.name.endsWith(".apk") }
|
?.filter { it.name.endsWith(".apk") }
|
||||||
@@ -141,6 +148,7 @@ object RestoreOperation {
|
|||||||
|
|
||||||
if (apkFiles.isEmpty()) return false
|
if (apkFiles.isEmpty()) return false
|
||||||
|
|
||||||
|
suspend fun doInstall(): Boolean {
|
||||||
// Build install command for multiple APKs (split APK support)
|
// Build install command for multiple APKs (split APK support)
|
||||||
val apkPaths = apkFiles.joinToString(" ") { "'${it.absolutePath.shellEscape()}'" }
|
val apkPaths = apkFiles.joinToString(" ") { "'${it.absolutePath.shellEscape()}'" }
|
||||||
|
|
||||||
@@ -167,7 +175,41 @@ object RestoreOperation {
|
|||||||
return result.isSuccess
|
return result.isSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun restoreData(appDir: File) {
|
suspend fun isInstalled(): Boolean {
|
||||||
|
val verifyResult = RootShell.exec("pm list packages '${packageName.shellEscape()}' 2>/dev/null")
|
||||||
|
return verifyResult.output.contains(packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First install attempt
|
||||||
|
val firstOk = doInstall()
|
||||||
|
if (!firstOk) {
|
||||||
|
Log.e(TAG, "installApk: $packageName — first install attempt failed")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify installation succeeded
|
||||||
|
if (isInstalled()) {
|
||||||
|
Log.i(TAG, "installApk: $packageName installed and verified")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.w(TAG, "installApk: $packageName installed but not detected — retrying once")
|
||||||
|
val retryOk = doInstall()
|
||||||
|
if (!retryOk) {
|
||||||
|
Log.e(TAG, "installApk: $packageName — retry install failed")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInstalled()) {
|
||||||
|
Log.i(TAG, "installApk: $packageName installed and verified (after retry)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.e(TAG, "installApk: $packageName — install reported success but package not found after retry")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun restoreData(packageName: String, userId: String, appDir: File, tarCmd: String, zstdCmd: String) {
|
||||||
val files = appDir.listFiles()
|
val files = appDir.listFiles()
|
||||||
if (files.isNullOrEmpty()) {
|
if (files.isNullOrEmpty()) {
|
||||||
Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}")
|
Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}")
|
||||||
@@ -178,27 +220,60 @@ object RestoreOperation {
|
|||||||
Log.w(TAG, "restoreData: no _data.tar in ${appDir.name}, found: ${files.map { it.name }}")
|
Log.w(TAG, "restoreData: no _data.tar in ${appDir.name}, found: ${files.map { it.name }}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build exclusion patterns for cache/temp directories
|
||||||
|
val dataPaths = listOf("/data/data/$packageName", "/data/user_de/$userId/$packageName")
|
||||||
|
val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup")
|
||||||
|
val excludeArgs = dataPaths.flatMap { dataPath ->
|
||||||
|
excludeFolders.flatMap { folder ->
|
||||||
|
listOf("--exclude='${dataPath.shellEscape()}/$folder'", "--exclude='${dataPath.shellEscape()}/$folder/*'")
|
||||||
|
}
|
||||||
|
}.joinToString(" ")
|
||||||
|
|
||||||
for (archive in dataFiles) {
|
for (archive in dataFiles) {
|
||||||
val archivePath = archive.absolutePath.shellEscape()
|
val archivePath = archive.absolutePath.shellEscape()
|
||||||
Log.d(TAG, "restoreData: found archive ${archive.name}")
|
Log.d(TAG, "restoreData: found archive ${archive.name}")
|
||||||
if (!isArchiveSafe(archive)) {
|
if (!isArchiveSafe(archive, zstdCmd)) {
|
||||||
Log.w(TAG, "restoreData: archive NOT SAFE, skipping: ${archive.name}")
|
Log.w(TAG, "restoreData: archive NOT SAFE, skipping: ${archive.name}")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val cmd = when {
|
|
||||||
|
// Build the extract command with exclusion flags
|
||||||
|
val baseCmd = when {
|
||||||
archive.name.endsWith(".zst") ->
|
archive.name.endsWith(".zst") ->
|
||||||
"zstd -d -c '$archivePath' | tar -xf - -C / 2>/dev/null"
|
"set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null"
|
||||||
archive.name.endsWith(".gz") ->
|
archive.name.endsWith(".gz") ->
|
||||||
"tar -xzf '$archivePath' -C / 2>/dev/null"
|
"$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null"
|
||||||
archive.name.endsWith(".tar") ->
|
archive.name.endsWith(".tar") ->
|
||||||
"tar -xf '$archivePath' -C / 2>/dev/null"
|
"$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null"
|
||||||
else -> { Log.w(TAG, "restoreData: unknown archive type ${archive.name}"); continue }
|
else -> { Log.w(TAG, "restoreData: unknown archive type ${archive.name}"); continue }
|
||||||
}
|
}
|
||||||
val result = RootShell.exec(cmd)
|
|
||||||
|
val result = RootShell.exec(baseCmd)
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
Log.i(TAG, "restoreData: extracted ${archive.name}")
|
Log.i(TAG, "restoreData: extracted ${archive.name}")
|
||||||
} else {
|
} else {
|
||||||
Log.e(TAG, "restoreData: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}")
|
Log.e(TAG, "restoreData: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}")
|
||||||
|
// Continue to try SELinux fix even if extraction had issues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore SELinux context on extracted data directories
|
||||||
|
for (dataPath in dataPaths) {
|
||||||
|
// Try to get the existing context (if the path already existed)
|
||||||
|
val existingContext = SELinuxUtil.getContext(dataPath)
|
||||||
|
val context = existingContext ?: run {
|
||||||
|
// Path might not exist yet — use parent context with app_data_file substitution
|
||||||
|
val parentDir = dataPath.substringBeforeLast("/")
|
||||||
|
val parentContext = SELinuxUtil.getContext(parentDir)
|
||||||
|
parentContext?.replace("system_data_file", "app_data_file")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context != null) {
|
||||||
|
Log.d(TAG, "restoreData: restoring SELinux context on $dataPath: $context")
|
||||||
|
SELinuxUtil.chcon(context, dataPath)
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "restoreData: could not determine SELinux context for $dataPath")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,13 +283,18 @@ object RestoreOperation {
|
|||||||
* or symbolic links pointing outside the tree.
|
* or symbolic links pointing outside the tree.
|
||||||
* Accepts both absolute and relative paths — tar implementations vary.
|
* Accepts both absolute and relative paths — tar implementations vary.
|
||||||
*/
|
*/
|
||||||
private suspend fun isArchiveSafe(archive: File): Boolean {
|
private suspend fun isArchiveSafe(archive: File, zstdCmd: String = "zstd"): Boolean {
|
||||||
val listCmd = if (archive.name.endsWith(".zst")) {
|
val listCmd = if (archive.name.endsWith(".zst")) {
|
||||||
"zstd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null"
|
"set -o pipefail; $zstdCmd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null"
|
||||||
} else {
|
} else {
|
||||||
"tar tf '${archive.absolutePath.shellEscape()}' 2>/dev/null"
|
"tar tf '${archive.absolutePath.shellEscape()}' 2>/dev/null"
|
||||||
}
|
}
|
||||||
val result = RootShell.exec(listCmd)
|
var result = RootShell.exec(listCmd)
|
||||||
|
// Fallback: try without pipefail (some Android shells don't support it)
|
||||||
|
if (!result.isSuccess && archive.name.endsWith(".zst")) {
|
||||||
|
val fallbackCmd = "$zstdCmd -d -c '${archive.absolutePath.shellEscape()}' 2>/dev/null | tar tf - 2>/dev/null"
|
||||||
|
result = RootShell.exec(fallbackCmd)
|
||||||
|
}
|
||||||
if (!result.isSuccess) return false
|
if (!result.isSuccess) return false
|
||||||
return !result.output.lines().any { line ->
|
return !result.output.lines().any { line ->
|
||||||
val path = line.substringBefore(" -> ")
|
val path = line.substringBefore(" -> ")
|
||||||
@@ -226,29 +306,39 @@ object RestoreOperation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun restoreObb(packageName: String, appDir: File) {
|
private suspend fun restoreObb(packageName: String, appDir: File, tarCmd: String, zstdCmd: String) {
|
||||||
val obbFiles = appDir.listFiles()
|
val obbFiles = appDir.listFiles()
|
||||||
?.filter { it.name.contains("_obb.tar") }
|
?.filter { it.name.contains("_obb.tar") }
|
||||||
?: return
|
?: return
|
||||||
|
|
||||||
|
if (obbFiles.isEmpty()) return
|
||||||
|
|
||||||
|
// Build exclusion patterns for OBB cache/temp directories
|
||||||
|
val obbPath = "/storage/emulated/0/Android/obb/$packageName"
|
||||||
|
val excludeFolders = listOf(".ota", "cache", "lib", "code_cache", "no_backup", "Backup_*")
|
||||||
|
val excludeArgs = excludeFolders.joinToString(" ") { "--exclude='${obbPath.shellEscape()}/$it' --exclude='${obbPath.shellEscape()}/$it/*'" }
|
||||||
|
|
||||||
for (archive in obbFiles) {
|
for (archive in obbFiles) {
|
||||||
if (!isArchiveSafe(archive)) continue
|
if (!isArchiveSafe(archive, zstdCmd)) continue
|
||||||
val archivePath = archive.absolutePath.shellEscape()
|
val archivePath = archive.absolutePath.shellEscape()
|
||||||
when {
|
when {
|
||||||
archive.name.endsWith(".zst") -> {
|
archive.name.endsWith(".zst") -> {
|
||||||
RootShell.exec("zstd -d -c '$archivePath' | tar -xf - -C / 2>/dev/null")
|
RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null")
|
||||||
}
|
}
|
||||||
archive.name.endsWith(".gz") -> {
|
archive.name.endsWith(".gz") -> {
|
||||||
RootShell.exec("tar -xzf '$archivePath' -C / 2>/dev/null")
|
RootShell.exec("$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null")
|
||||||
}
|
}
|
||||||
archive.name.endsWith(".tar") -> {
|
archive.name.endsWith(".tar") -> {
|
||||||
RootShell.exec("tar -xf '$archivePath' -C / 2>/dev/null")
|
RootShell.exec("$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fix OBB permissions
|
// Fix OBB permissions: resolve GID from parent directory instead of hardcoding 1023
|
||||||
RootShell.exec("chown -R 1023:1023 /storage/emulated/0/Android/obb/${packageName.shellEscape()}/ 2>/dev/null")
|
val gidResult = RootShell.exec("stat -c %g '${obbPath.shellEscape()}' 2>/dev/null")
|
||||||
|
val gid = gidResult.output.trim().toIntOrNull() ?: 1023 // fallback to media_rw gid
|
||||||
|
RootShell.exec("chown -R $gid:$gid '${obbPath.shellEscape()}/' 2>/dev/null")
|
||||||
|
Log.i(TAG, "restoreObb: set ownership to $gid:$gid on $obbPath")
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun restoreSsaid(packageName: String, appDir: File, userId: String) {
|
private suspend fun restoreSsaid(packageName: String, appDir: File, userId: String) {
|
||||||
@@ -267,23 +357,60 @@ object RestoreOperation {
|
|||||||
.trim()
|
.trim()
|
||||||
.toIntOrNull()
|
.toIntOrNull()
|
||||||
|
|
||||||
if (uid != null) {
|
if (uid == null) {
|
||||||
// Use settings put secure to set SSAID (more reliable than XML manipulation)
|
Log.w(TAG, "restoreSsaid: could not resolve UID for $packageName")
|
||||||
val result = RootShell.exec("settings put secure ssaid_$uid '$ssaidValue'")
|
return
|
||||||
if (result.isSuccess) {
|
|
||||||
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName (uid=$uid)")
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, "restoreSsaid: failed to set SSAID for $packageName: ${result.error}")
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Log.w(TAG, "restoreSsaid: could not resolve UID for $packageName, falling back to XML edit")
|
// Try XML-based approach first (more reliable across Android versions)
|
||||||
// Fallback: edit settings_ssaid.xml directly
|
|
||||||
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
|
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
|
||||||
RootShell.exec(
|
val xmlSuccess = run {
|
||||||
"grep -v '${packageName.shellEscape()}' '$targetFile' > '$targetFile.tmp' && " +
|
// Check if file exists
|
||||||
"sed -i '\$ i ${ssaidValue.shellEscape()}' '$targetFile.tmp' && " +
|
val checkResult = RootShell.exec("test -f '$targetFile' && echo 'exists'")
|
||||||
"mv '$targetFile.tmp' '$targetFile'"
|
if (!checkResult.output.contains("exists")) {
|
||||||
)
|
Log.d(TAG, "restoreSsaid: $targetFile does not exist, will use settings command")
|
||||||
|
return@run false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a UUID for the new entry
|
||||||
|
val uuidResult = RootShell.exec("cat /proc/sys/kernel/random/uuid 2>/dev/null")
|
||||||
|
val id = uuidResult.output.trim()
|
||||||
|
if (id.length != 36) { // UUID format check
|
||||||
|
Log.w(TAG, "restoreSsaid: could not generate UUID (got '$id'), falling back")
|
||||||
|
return@run false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove existing entry for this package and insert new one before </settings>
|
||||||
|
val manipCmd = buildString {
|
||||||
|
append("sed -i \"/package.*${packageName.shellEscape()}/d\" '$targetFile' && ")
|
||||||
|
append("sed -i \"s#</settings>#<setting id=\\\"$id\\\" package=\\\"${packageName.shellEscape()}\\\" value=\\\"${ssaidValue.shellEscape()}\\\" defaultValue=\\\"default\\\" />\\n</settings>#\" '$targetFile'")
|
||||||
|
}
|
||||||
|
val result = RootShell.exec(manipCmd)
|
||||||
|
if (!result.isSuccess) {
|
||||||
|
Log.w(TAG, "restoreSsaid: XML edit failed: ${result.error}")
|
||||||
|
return@run false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the package entry was added by checking if it appears in the file now
|
||||||
|
val verifyCmd = RootShell.exec("grep -c \"${packageName.shellEscape()}\" '$targetFile' 2>/dev/null")
|
||||||
|
val entryCount = verifyCmd.output.trim().toIntOrNull() ?: 0
|
||||||
|
if (entryCount > 0) {
|
||||||
|
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via XML (uid=$uid)")
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "restoreSsaid: XML edit completed but entry not found, falling back")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use settings put secure if XML approach failed
|
||||||
|
if (!xmlSuccess) {
|
||||||
|
val result = RootShell.exec("settings put secure ssaid_$uid '${ssaidValue.shellEscape()}'")
|
||||||
|
if (result.isSuccess) {
|
||||||
|
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via settings (uid=$uid)")
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "restoreSsaid: failed to set SSAID for $packageName: ${result.error}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,43 +418,109 @@ object RestoreOperation {
|
|||||||
val permFile = File(appDir, "permissions.txt")
|
val permFile = File(appDir, "permissions.txt")
|
||||||
if (!permFile.exists()) return
|
if (!permFile.exists()) return
|
||||||
|
|
||||||
// dumpsys 输出格式: "android.permission.XXX: granted=true" 或 "permission.XXX: granted=true"
|
// Parse permissions from dumpsys output.
|
||||||
// 各 Android 版本输出有差异,try-catch 兜底避免单权限失败中断全部
|
// Format: "android.permission.XXX: granted=true" or "android.permission.XXX: granted=false"
|
||||||
val perms = try {
|
val parsedPerms = try {
|
||||||
permFile.readLines()
|
permFile.readLines().mapNotNull { line ->
|
||||||
.filter { it.contains("granted=true") }
|
val name = line.substringBefore(":").trim().takeIf { it.isNotEmpty() && it.contains(".") } ?: return@mapNotNull null
|
||||||
.mapNotNull { line ->
|
val granted = line.contains("granted=true")
|
||||||
line.substringBefore(":")
|
Pair(name, granted)
|
||||||
.trim()
|
|
||||||
.takeIf { it.isNotEmpty() && it.contains(".") }
|
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
|
|
||||||
|
if (parsedPerms.isEmpty()) return
|
||||||
|
|
||||||
val pkgEsc = packageName.shellEscape()
|
val pkgEsc = packageName.shellEscape()
|
||||||
for (perm in perms) {
|
|
||||||
|
// Reset app ops first (clears any previous modes)
|
||||||
|
RootShell.exec("appops reset '$pkgEsc' 2>/dev/null")
|
||||||
|
|
||||||
|
val grantedPerms = parsedPerms.filter { it.second }.map { it.first }
|
||||||
|
val deniedPerms = parsedPerms.filter { !it.second }.map { it.first }
|
||||||
|
|
||||||
|
// Grant runtime permissions that were previously granted
|
||||||
|
for (perm in grantedPerms) {
|
||||||
val result = RootShell.exec("pm grant '$pkgEsc' '${perm.shellEscape()}' 2>&1")
|
val result = RootShell.exec("pm grant '$pkgEsc' '${perm.shellEscape()}' 2>&1")
|
||||||
if (!result.isSuccess) {
|
if (!result.isSuccess) {
|
||||||
android.util.Log.w("RestoreOperation", "pm grant failed for $packageName: $perm — ${result.output}")
|
Log.w(TAG, "restorePermissions: pm grant failed for $packageName: $perm — ${result.output}")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fixDataOwnership(packageName: String, userId: String) {
|
// Revoke runtime permissions that were explicitly denied
|
||||||
|
for (perm in deniedPerms) {
|
||||||
|
val result = RootShell.exec("pm revoke '$pkgEsc' '${perm.shellEscape()}' 2>&1")
|
||||||
|
if (!result.isSuccess) {
|
||||||
|
// Revoking a permission that isn't granted is not an error — just log at debug level
|
||||||
|
Log.d(TAG, "restorePermissions: pm revoke for $packageName: $perm — ${result.output}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "restorePermissions: ${grantedPerms.size} granted, ${deniedPerms.size} revoked for $packageName")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve app UID using multiple methods for robustness across Android versions. */
|
||||||
|
private suspend fun resolveAppUid(packageName: String): Int? {
|
||||||
val pkgEsc = packageName.shellEscape()
|
val pkgEsc = packageName.shellEscape()
|
||||||
val uidEsc = userId.shellEscape()
|
// Method 1: pm list packages -U (reliable, consistent output format)
|
||||||
val uidResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1")
|
val pmResult = RootShell.exec("pm list packages -U 2>/dev/null | grep '${pkgEsc}$'")
|
||||||
val uid = uidResult.output
|
val pmUid = pmResult.output
|
||||||
|
.substringAfter(" uid:")
|
||||||
|
.trim()
|
||||||
|
.toIntOrNull()
|
||||||
|
if (pmUid != null) return pmUid
|
||||||
|
|
||||||
|
// Method 2: dumpsys package (fallback for older Android)
|
||||||
|
val dsResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1")
|
||||||
|
val dsUid = dsResult.output
|
||||||
.substringAfter("userId=", "")
|
.substringAfter("userId=", "")
|
||||||
.substringBefore(" ")
|
.substringBefore(" ")
|
||||||
.substringBefore(",")
|
.substringBefore(",")
|
||||||
.trim()
|
.trim()
|
||||||
.toIntOrNull()
|
.toIntOrNull()
|
||||||
|
if (dsUid != null) return dsUid
|
||||||
|
|
||||||
if (uid != null) {
|
// Method 3: dumpsys with userId: separator (AOSP variant)
|
||||||
RootShell.exec("chown -R $uid:$uid /data/data/$pkgEsc/ 2>/dev/null")
|
val ds2Result = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId:' | head -1")
|
||||||
RootShell.exec("chown -R $uid:$uid /data/user_de/$uidEsc/$pkgEsc/ 2>/dev/null")
|
val ds2Uid = ds2Result.output
|
||||||
RootShell.exec("restorecon -R /data/data/$pkgEsc/ 2>/dev/null")
|
.substringAfter("userId:", "")
|
||||||
RootShell.exec("restorecon -R /data/user_de/$uidEsc/$pkgEsc/ 2>/dev/null")
|
.substringBefore(" ")
|
||||||
|
.trim()
|
||||||
|
.toIntOrNull()
|
||||||
|
return ds2Uid
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fixDataOwnership(packageName: String, userId: String) {
|
||||||
|
val pkgEsc = packageName.shellEscape()
|
||||||
|
val uidEsc = userId.shellEscape()
|
||||||
|
|
||||||
|
val uid = resolveAppUid(packageName)
|
||||||
|
if (uid == null) {
|
||||||
|
Log.w(TAG, "fixDataOwnership: could not resolve UID for $packageName — data will be inaccessible")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// USER and USER_DE use uid:uid (app's own group)
|
||||||
|
val dataPaths = listOf(
|
||||||
|
"/data/data/$pkgEsc",
|
||||||
|
"/data/user_de/$uidEsc/$pkgEsc"
|
||||||
|
)
|
||||||
|
|
||||||
|
for (dataPath in dataPaths) {
|
||||||
|
RootShell.exec("chown -R $uid:$uid '$dataPath/' 2>/dev/null")
|
||||||
|
|
||||||
|
// Restore SELinux context instead of using restorecon (which applies defaults)
|
||||||
|
val existingContext = SELinuxUtil.getContext(dataPath)
|
||||||
|
val context = existingContext ?: run {
|
||||||
|
val parentDir = dataPath.substringBeforeLast("/")
|
||||||
|
val parentContext = SELinuxUtil.getContext(parentDir)
|
||||||
|
parentContext?.replace("system_data_file", "app_data_file")
|
||||||
|
}
|
||||||
|
if (context != null) {
|
||||||
|
SELinuxUtil.chcon(context, dataPath)
|
||||||
|
Log.d(TAG, "fixDataOwnership: restored SELinux context on $dataPath: $context")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "fixDataOwnership: could not determine SELinux context for $dataPath")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.example.androidbackupgui.backup
|
||||||
|
|
||||||
|
import com.example.androidbackupgui.root.RootShell
|
||||||
|
import com.example.androidbackupgui.root.shellEscape
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SELinux context utilities for restoring file security labels.
|
||||||
|
* Mirrors the approach from Android-DataBackup (Xayah) SELinuxUtil.kt.
|
||||||
|
*/
|
||||||
|
object SELinuxUtil {
|
||||||
|
|
||||||
|
private const val TAG = "SELinuxUtil"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query the SELinux context of a path.
|
||||||
|
* Returns the full SELinux label (e.g., "u:object_r:app_data_file:s0:c512,c768")
|
||||||
|
* or null if the path doesn't exist or the query fails.
|
||||||
|
*/
|
||||||
|
suspend fun getContext(path: String): String? {
|
||||||
|
val escaped = path.shellEscape()
|
||||||
|
val result = RootShell.exec("ls -Zd '$escaped' 2>/dev/null | awk 'NF>1{print \$1}'")
|
||||||
|
if (!result.isSuccess) return null
|
||||||
|
val context = result.output.trim()
|
||||||
|
return context.ifBlank { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a SELinux context on a path recursively.
|
||||||
|
* Equivalent to: chcon -hR [context] [path]/
|
||||||
|
*/
|
||||||
|
suspend fun chcon(context: String, path: String): Boolean {
|
||||||
|
val ctxEsc = context.shellEscape()
|
||||||
|
val pathEsc = path.shellEscape()
|
||||||
|
val result = RootShell.exec("chcon -hR '$ctxEsc' '$pathEsc/' 2>/dev/null")
|
||||||
|
if (result.isSuccess) return true
|
||||||
|
val fallback = RootShell.exec("chcon -R '$ctxEsc' '$pathEsc/' 2>/dev/null")
|
||||||
|
if (!fallback.isSuccess) {
|
||||||
|
Log.w(TAG, "chcon failed (both primary and fallback): $path")
|
||||||
|
}
|
||||||
|
return fallback.isSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import jcifs.smb.SmbFileInputStream
|
|||||||
import jcifs.smb.SmbFileOutputStream
|
import jcifs.smb.SmbFileOutputStream
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ class SmbTransport(
|
|||||||
|
|
||||||
private fun smbFile(path: String): SmbFile = SmbFile(buildUrl(path), context)
|
private fun smbFile(path: String): SmbFile = SmbFile(buildUrl(path), context)
|
||||||
|
|
||||||
override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): Result<Unit> =
|
override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val localFile = File(localPath)
|
val localFile = File(localPath)
|
||||||
@@ -83,14 +84,16 @@ class SmbTransport(
|
|||||||
}
|
}
|
||||||
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
|
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
|
||||||
Log.i(TAG, "upload $localPath -> ${buildUrl(remotePath)} ($fileSize bytes)")
|
Log.i(TAG, "upload $localPath -> ${buildUrl(remotePath)} ($fileSize bytes)")
|
||||||
Result.success(Unit)
|
AppResult.Success(Unit)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "upload failed: ${buildUrl(remotePath)}", e)
|
Log.e(TAG, "upload failed: ${buildUrl(remotePath)}", e)
|
||||||
Result.failure(Exception("SMB upload failed: ${e.message}", e))
|
err(AppError.Remote("SMB 上传失败", "upload", cause = e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): Result<Unit> =
|
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val localFile = File(localPath)
|
val localFile = File(localPath)
|
||||||
@@ -114,19 +117,21 @@ class SmbTransport(
|
|||||||
}
|
}
|
||||||
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
|
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
|
||||||
Log.d(TAG, "download ${buildUrl(remotePath)} -> $localPath (${localFile.length()} bytes)")
|
Log.d(TAG, "download ${buildUrl(remotePath)} -> $localPath (${localFile.length()} bytes)")
|
||||||
Result.success(Unit)
|
AppResult.Success(Unit)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "download failed: $remotePath", e)
|
Log.e(TAG, "download failed: $remotePath", e)
|
||||||
Result.failure(Exception("SMB download failed: ${e.message}", e))
|
err(AppError.Remote("SMB 下载失败", "download", cause = e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun listFiles(remoteDir: String): Result<List<RemoteTransport.RemoteFileInfo>> =
|
override suspend fun listFiles(remoteDir: String): AppResult<List<RemoteTransport.RemoteFileInfo>> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val dir = smbFile(remoteDir)
|
val dir = smbFile(remoteDir)
|
||||||
if (!dir.exists() || !dir.isDirectory) {
|
if (!dir.exists() || !dir.isDirectory) {
|
||||||
return@withContext Result.failure(FileNotFoundException(remoteDir))
|
return@withContext err(AppError.Remote("远端路径不存在", "list", isNotFound = true))
|
||||||
}
|
}
|
||||||
// SmbFile.getName() in jcifs-ng 2.1.x is broken — it concatenates
|
// SmbFile.getName() in jcifs-ng 2.1.x is broken — it concatenates
|
||||||
// parent-dir + filename without separator. Use the URL to extract
|
// parent-dir + filename without separator. Use the URL to extract
|
||||||
@@ -154,66 +159,74 @@ class SmbTransport(
|
|||||||
}
|
}
|
||||||
?: emptyList()
|
?: emptyList()
|
||||||
Log.d(TAG, "listFiles $remoteDir -> ${entries.size} entries: ${entries.joinToString { "${it.name}(${if (it.isDirectory) "d" else "f"},${it.size})" }}")
|
Log.d(TAG, "listFiles $remoteDir -> ${entries.size} entries: ${entries.joinToString { "${it.name}(${if (it.isDirectory) "d" else "f"},${it.size})" }}")
|
||||||
Result.success(entries)
|
AppResult.Success(entries)
|
||||||
} catch (e: SmbException) {
|
} catch (e: SmbException) {
|
||||||
if (e.ntStatus == 0xC0000034.toInt()) {
|
if (e.ntStatus == 0xC0000034.toInt()) {
|
||||||
return@withContext Result.failure(FileNotFoundException(remoteDir))
|
return@withContext err(AppError.Remote("远端路径不存在", "list", isNotFound = true))
|
||||||
}
|
}
|
||||||
Log.e(TAG, "listFiles failed: $remoteDir", e)
|
Log.e(TAG, "listFiles failed: $remoteDir", e)
|
||||||
Result.failure(Exception("SMB list failed: ${e.message}", e))
|
err(AppError.Remote("SMB 列表失败", "list", cause = e))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "listFiles failed: $remoteDir", e)
|
Log.e(TAG, "listFiles failed: $remoteDir", e)
|
||||||
Result.failure(Exception("SMB list failed: ${e.message}", e))
|
err(AppError.Remote("SMB 列表失败", "list", cause = e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override suspend fun mkdirs(remotePath: String): Result<Unit> =
|
override suspend fun mkdirs(remotePath: String): AppResult<Unit> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val dir = smbFile(remotePath)
|
val dir = smbFile(remotePath)
|
||||||
if (!dir.exists()) dir.mkdirs()
|
if (!dir.exists()) dir.mkdirs()
|
||||||
Result.success(Unit)
|
AppResult.Success(Unit)
|
||||||
} catch (e: SmbException) {
|
} catch (e: SmbException) {
|
||||||
// STATUS_OBJECT_NAME_COLLISION (0xC0000035): directory already exists — not an error
|
// STATUS_OBJECT_NAME_COLLISION (0xC0000035): directory already exists — not an error
|
||||||
if (e.ntStatus == 0xC0000035.toInt()) {
|
if (e.ntStatus == 0xC0000035.toInt()) {
|
||||||
Result.success(Unit)
|
AppResult.Success(Unit)
|
||||||
} else {
|
} else {
|
||||||
Log.e(TAG, "mkdirs failed: $remotePath — ${e.message}")
|
Log.e(TAG, "mkdirs failed: $remotePath — ${e.message}")
|
||||||
Result.failure(Exception("SMB mkdirs failed: ${e.message}", e))
|
err(AppError.Remote("SMB 创建目录失败", "mkdirs", cause = e))
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "mkdirs failed: $remotePath — ${e.message}")
|
Log.e(TAG, "mkdirs failed: $remotePath — ${e.message}")
|
||||||
Result.failure(Exception("SMB mkdirs failed: ${e.message}", e))
|
err(AppError.Remote("SMB 创建目录失败", "mkdirs", cause = e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun delete(remotePath: String): Result<Unit> =
|
override suspend fun delete(remotePath: String): AppResult<Unit> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val file = smbFile(remotePath)
|
val file = smbFile(remotePath)
|
||||||
if (file.exists()) file.delete()
|
if (file.exists()) file.delete()
|
||||||
Result.success(Unit)
|
AppResult.Success(Unit)
|
||||||
} catch (e: SmbException) {
|
} catch (e: SmbException) {
|
||||||
// STATUS_OBJECT_NAME_NOT_FOUND (0xC0000034): file already gone — not an error
|
// STATUS_OBJECT_NAME_NOT_FOUND (0xC0000034): file already gone — not an error
|
||||||
if (e.ntStatus == 0xC0000034.toInt()) {
|
if (e.ntStatus == 0xC0000034.toInt()) {
|
||||||
Result.success(Unit)
|
AppResult.Success(Unit)
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "delete failed: $remotePath — ${e.message}")
|
Log.w(TAG, "delete failed: $remotePath — ${e.message}")
|
||||||
Result.failure(Exception("SMB delete failed: ${e.message}", e))
|
err(AppError.Remote("SMB 删除失败", "delete", cause = e))
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "delete failed: $remotePath — ${e.message}")
|
Log.w(TAG, "delete failed: $remotePath — ${e.message}")
|
||||||
Result.failure(Exception("SMB delete failed: ${e.message}", e))
|
err(AppError.Remote("SMB 删除失败", "delete", cause = e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun exists(remotePath: String): Result<Boolean> =
|
override suspend fun exists(remotePath: String): AppResult<Boolean> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Result.success(smbFile(remotePath).exists())
|
AppResult.Success(smbFile(remotePath).exists())
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(Exception("SMB exists check failed: ${e.message}", e))
|
err(AppError.Remote("SMB 检查失败", "exists", cause = e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
|
|||||||
import com.thegrizzlylabs.sardineandroid.impl.SardineException
|
import com.thegrizzlylabs.sardineandroid.impl.SardineException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -31,16 +32,14 @@ class WebdavTransport(
|
|||||||
return "$baseUrl/$cleanPath"
|
return "$baseUrl/$cleanPath"
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): Result<Unit> =
|
override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val url = buildUrl(remotePath)
|
val url = buildUrl(remotePath)
|
||||||
val file = File(localPath)
|
val file = File(localPath)
|
||||||
val fileSize = file.length()
|
val fileSize = file.length()
|
||||||
if (fileSize > 50 * 1024 * 1024L) {
|
if (fileSize > 50 * 1024 * 1024L) {
|
||||||
return@withContext Result.failure(
|
return@withContext err(AppError.Remote("WebDAV 上传: 文件过大 (${fileSize / 1024 / 1024}MB), 上限 50MB", "upload"))
|
||||||
Exception("WebDAV upload: file too large (${fileSize / 1024 / 1024}MB), max 50MB")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Log.d(TAG, "upload $localPath -> $url ($fileSize bytes)")
|
Log.d(TAG, "upload $localPath -> $url ($fileSize bytes)")
|
||||||
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
|
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
|
||||||
@@ -61,14 +60,16 @@ class WebdavTransport(
|
|||||||
}
|
}
|
||||||
sardine.put(url, data, "application/octet-stream")
|
sardine.put(url, data, "application/octet-stream")
|
||||||
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
|
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
|
||||||
Result.success(Unit)
|
AppResult.Success(Unit)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "upload failed: $remotePath", e)
|
Log.e(TAG, "upload failed: $remotePath", e)
|
||||||
Result.failure(Exception("WebDAV upload failed: ${e.message}", e))
|
err(AppError.Remote("WebDAV 上传失败", "upload", cause = e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): Result<Unit> =
|
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val url = buildUrl(remotePath)
|
val url = buildUrl(remotePath)
|
||||||
@@ -91,13 +92,15 @@ class WebdavTransport(
|
|||||||
}
|
}
|
||||||
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
|
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
|
||||||
Log.d(TAG, "download $url -> $localPath (${localFile.length()} bytes)")
|
Log.d(TAG, "download $url -> $localPath (${localFile.length()} bytes)")
|
||||||
Result.success(Unit)
|
AppResult.Success(Unit)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "download failed: $remotePath", e)
|
Log.e(TAG, "download failed: $remotePath", e)
|
||||||
Result.failure(Exception("WebDAV download failed: ${e.message}", e))
|
err(AppError.Remote("WebDAV 下载失败", "download", cause = e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
override suspend fun listFiles(remoteDir: String): Result<List<RemoteTransport.RemoteFileInfo>> =
|
override suspend fun listFiles(remoteDir: String): AppResult<List<RemoteTransport.RemoteFileInfo>> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val url = buildUrl(remoteDir)
|
val url = buildUrl(remoteDir)
|
||||||
@@ -116,7 +119,9 @@ class WebdavTransport(
|
|||||||
isDirectory = it.isDirectory
|
isDirectory = it.isDirectory
|
||||||
) }
|
) }
|
||||||
Log.d(TAG, "listFiles $remoteDir -> ${entries.size} entries")
|
Log.d(TAG, "listFiles $remoteDir -> ${entries.size} entries")
|
||||||
Result.success(entries)
|
AppResult.Success(entries)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Only treat 404 as empty for non-root paths; the caller (listRemoteRecursive)
|
// Only treat 404 as empty for non-root paths; the caller (listRemoteRecursive)
|
||||||
// handles the distinction. We propagate the error so the caller can decide.
|
// handles the distinction. We propagate the error so the caller can decide.
|
||||||
@@ -124,14 +129,14 @@ class WebdavTransport(
|
|||||||
if (is404) {
|
if (is404) {
|
||||||
// Return a failure with a distinguishable marker so callers can check
|
// Return a failure with a distinguishable marker so callers can check
|
||||||
Log.d(TAG, "listFiles $remoteDir -> 404 (not found)")
|
Log.d(TAG, "listFiles $remoteDir -> 404 (not found)")
|
||||||
return@withContext Result.failure(FileNotFoundException(remoteDir))
|
return@withContext err(AppError.Remote("远端路径不存在", "list", isNotFound = true))
|
||||||
}
|
}
|
||||||
Log.e(TAG, "listFiles failed: $remoteDir", e)
|
Log.e(TAG, "listFiles failed: $remoteDir", e)
|
||||||
Result.failure(Exception("WebDAV list failed: ${e.message}", e))
|
err(AppError.Remote("WebDAV 列表失败", "list", cause = e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun mkdirs(remotePath: String): Result<Unit> =
|
override suspend fun mkdirs(remotePath: String): AppResult<Unit> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val parts = remotePath.trimStart('/').split("/")
|
val parts = remotePath.trimStart('/').split("/")
|
||||||
@@ -139,34 +144,40 @@ class WebdavTransport(
|
|||||||
for (part in parts) {
|
for (part in parts) {
|
||||||
current = if (current.isEmpty()) part else "$current/$part"
|
current = if (current.isEmpty()) part else "$current/$part"
|
||||||
try { sardine.createDirectory(buildUrl(current)) }
|
try { sardine.createDirectory(buildUrl(current)) }
|
||||||
catch (_: Exception) { /* already exists or parent missing, continue */ }
|
catch (_: Exception) { Log.w(TAG, "mkdirs: failed to create $current"); continue }
|
||||||
}
|
}
|
||||||
Result.success(Unit)
|
AppResult.Success(Unit)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "mkdirs failed: $remotePath — ${e.message}")
|
Log.w(TAG, "mkdirs failed: $remotePath — ${e.message}")
|
||||||
Result.success(Unit) // best-effort; upload will fail if dir can't be created
|
AppResult.Success(Unit) // best-effort; upload will fail if dir can't be created
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun delete(remotePath: String): Result<Unit> =
|
override suspend fun delete(remotePath: String): AppResult<Unit> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val url = buildUrl(remotePath)
|
val url = buildUrl(remotePath)
|
||||||
sardine.delete(url)
|
sardine.delete(url)
|
||||||
Result.success(Unit)
|
AppResult.Success(Unit)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "delete failed (ignoring): $remotePath — ${e.message}")
|
Log.w(TAG, "delete failed (ignoring): $remotePath — ${e.message}")
|
||||||
Result.success(Unit)
|
err(AppError.Remote("WebDAV 删除失败", "delete", cause = e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun exists(remotePath: String): Result<Boolean> =
|
override suspend fun exists(remotePath: String): AppResult<Boolean> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val result = sardine.exists(buildUrl(remotePath))
|
val result = sardine.exists(buildUrl(remotePath))
|
||||||
Result.success(result)
|
AppResult.Success(result)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(Exception("WebDAV exists check failed: ${e.message}", e))
|
err(AppError.Remote("WebDAV 检查失败", "exists", cause = e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.example.androidbackupgui.root
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.topjohnwu.superuser.Shell
|
import com.topjohnwu.superuser.Shell
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
@@ -67,6 +68,7 @@ object RootShell {
|
|||||||
|
|
||||||
suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult =
|
suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
ensureActive()
|
||||||
try {
|
try {
|
||||||
val result = withTimeout(timeoutMs) {
|
val result = withTimeout(timeoutMs) {
|
||||||
Shell.cmd(command).exec()
|
Shell.cmd(command).exec()
|
||||||
@@ -84,4 +86,17 @@ object RootShell {
|
|||||||
ShellResult("", e.message ?: "Unknown error", -1)
|
ShellResult("", e.message ?: "Unknown error", -1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全执行 root shell 命令,自动 shellEscape 每个参数。
|
||||||
|
* @param parts 命令和参数列表,第一个元素是命令本身
|
||||||
|
* @param timeoutMs 超时毫秒
|
||||||
|
*/
|
||||||
|
suspend fun execSafe(
|
||||||
|
parts: List<String>,
|
||||||
|
timeoutMs: Long = COMMAND_TIMEOUT_MS
|
||||||
|
): ShellResult = exec(
|
||||||
|
command = parts.joinToString(" ") { "'${it.shellEscape()}'" },
|
||||||
|
timeoutMs = timeoutMs
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import com.example.androidbackupgui.backup.BackupService
|
|||||||
import com.example.androidbackupgui.backup.ResticBinary
|
import com.example.androidbackupgui.backup.ResticBinary
|
||||||
import com.example.androidbackupgui.backup.ResticWrapper
|
import com.example.androidbackupgui.backup.ResticWrapper
|
||||||
import com.example.androidbackupgui.backup.WifiManager
|
import com.example.androidbackupgui.backup.WifiManager
|
||||||
|
import com.example.androidbackupgui.backup.AppResult
|
||||||
import com.example.androidbackupgui.backup.RemoteTransport
|
import com.example.androidbackupgui.backup.RemoteTransport
|
||||||
import com.example.androidbackupgui.databinding.FragmentBackupBinding
|
import com.example.androidbackupgui.databinding.FragmentBackupBinding
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -69,7 +70,7 @@ class BackupFragment : Fragment() {
|
|||||||
applySortFilter()
|
applySortFilter()
|
||||||
}
|
}
|
||||||
binding.selectAllButton.setOnClickListener {
|
binding.selectAllButton.setOnClickListener {
|
||||||
selectedApps.addAll(apps.map { it.packageName })
|
selectedApps.addAll(apps.map { it.packageName.value })
|
||||||
applySortFilter()
|
applySortFilter()
|
||||||
}
|
}
|
||||||
binding.deselectAllButton.setOnClickListener {
|
binding.deselectAllButton.setOnClickListener {
|
||||||
@@ -118,7 +119,7 @@ class BackupFragment : Fragment() {
|
|||||||
val system = AppScanner.scanSystem(ctx, config, userId = selectedUserId)
|
val system = AppScanner.scanSystem(ctx, config, userId = selectedUserId)
|
||||||
apps = if (showSystemApps) thirdParty + system else thirdParty
|
apps = if (showSystemApps) thirdParty + system else thirdParty
|
||||||
selectedApps.clear()
|
selectedApps.clear()
|
||||||
selectedApps.addAll(apps.map { it.packageName })
|
selectedApps.addAll(apps.map { it.packageName.value })
|
||||||
|
|
||||||
binding.statusText.text = "共找到 ${apps.size} 个应用,全部已选中"
|
binding.statusText.text = "共找到 ${apps.size} 个应用,全部已选中"
|
||||||
binding.backupButton.isEnabled = apps.isNotEmpty()
|
binding.backupButton.isEnabled = apps.isNotEmpty()
|
||||||
@@ -148,8 +149,17 @@ class BackupFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun startBackup() {
|
private fun startBackup() {
|
||||||
val toBackup = apps.filter { it.packageName in selectedApps }
|
val toBackup = apps.filter { it.packageName.value in selectedApps }
|
||||||
if (toBackup.isEmpty()) return
|
if (toBackup.isEmpty()) return
|
||||||
|
|
||||||
|
// Check restic local repo availability before doing any work
|
||||||
|
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank() &&
|
||||||
|
config.resticBackend == "local" && !File(config.resticRepo, "config").exists()
|
||||||
|
) {
|
||||||
|
binding.statusText.text = "restic 本地仓库未初始化,请先在设置中初始化"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setRunning(true)
|
setRunning(true)
|
||||||
binding.backupButton.isEnabled = false
|
binding.backupButton.isEnabled = false
|
||||||
binding.scanButton.isEnabled = false
|
binding.scanButton.isEnabled = false
|
||||||
@@ -167,7 +177,6 @@ class BackupFragment : Fragment() {
|
|||||||
val outputDir = File(config.outputPath.ifEmpty {
|
val outputDir = File(config.outputPath.ifEmpty {
|
||||||
requireContext().filesDir.absolutePath
|
requireContext().filesDir.absolutePath
|
||||||
})
|
})
|
||||||
WifiManager.backup(outputDir)
|
|
||||||
val result = BackupOperation.backupApps(
|
val result = BackupOperation.backupApps(
|
||||||
context = requireContext(),
|
context = requireContext(),
|
||||||
apps = toBackup,
|
apps = toBackup,
|
||||||
@@ -175,13 +184,15 @@ class BackupFragment : Fragment() {
|
|||||||
outputDir = outputDir,
|
outputDir = outputDir,
|
||||||
userId = selectedUserId.toString(),
|
userId = selectedUserId.toString(),
|
||||||
onProgress = { progress ->
|
onProgress = { progress ->
|
||||||
val label = toBackup.find { it.packageName == progress.packageName }?.label
|
val label = toBackup.find { it.packageName.value == progress.packageName }?.label
|
||||||
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
|
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
|
||||||
binding.statusText.text =
|
updateStatus("[${progress.current}/${progress.total}] $name: ${progress.message}")
|
||||||
"[${progress.current}/${progress.total}] $name: ${progress.message}"
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Store WiFi config inside Backup_* directory so restic/local restore can find it
|
||||||
|
WifiManager.backup(File(result.outputDir))
|
||||||
|
|
||||||
// If restic is enabled, snapshot to repository
|
// If restic is enabled, snapshot to repository
|
||||||
var resticSummary: ResticWrapper.BackupSummary? = null
|
var resticSummary: ResticWrapper.BackupSummary? = null
|
||||||
var resticError: String? = null
|
var resticError: String? = null
|
||||||
@@ -194,11 +205,11 @@ class BackupFragment : Fragment() {
|
|||||||
|
|
||||||
if (config.resticBackend == "local") {
|
if (config.resticBackend == "local") {
|
||||||
if (!File(config.resticRepo, "config").exists()) {
|
if (!File(config.resticRepo, "config").exists()) {
|
||||||
binding.statusText.text = "restic 本地仓库未初始化,请先在设置中初始化"
|
updateStatus("restic 本地仓库未初始化,请先在设置中初始化")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.statusText.text = "正在写入 restic 去重仓库…"
|
updateStatus("正在写入 restic 去重仓库…")
|
||||||
val resticResult = ResticWrapper.backup(
|
val resticResult = ResticWrapper.backup(
|
||||||
repoPath = config.resticRepo,
|
repoPath = config.resticRepo,
|
||||||
password = config.resticPassword,
|
password = config.resticPassword,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.example.androidbackupgui.ui
|
package com.example.androidbackupgui.ui
|
||||||
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.util.TypedValue
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.CheckBox
|
import android.widget.CheckBox
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
@@ -28,12 +29,13 @@ class PackageListAdapter(
|
|||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
val ctx = parent.context
|
val ctx = parent.context
|
||||||
|
val res = ctx.resources
|
||||||
val card = MaterialCardView(ctx).apply {
|
val card = MaterialCardView(ctx).apply {
|
||||||
layoutParams = ViewGroup.MarginLayoutParams(
|
layoutParams = ViewGroup.MarginLayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
).apply { setMargins(0, 0, 0, 8) }
|
).apply { setMargins(0, 0, 0, res.getDimensionPixelSize(R.dimen.card_margin_bottom)) }
|
||||||
radius = 12f
|
radius = res.getDimension(R.dimen.card_radius)
|
||||||
cardElevation = 0f
|
cardElevation = 0f
|
||||||
strokeWidth = 0
|
strokeWidth = 0
|
||||||
setCardBackgroundColor(
|
setCardBackgroundColor(
|
||||||
@@ -42,13 +44,13 @@ class PackageListAdapter(
|
|||||||
}
|
}
|
||||||
val layout = LinearLayout(ctx).apply {
|
val layout = LinearLayout(ctx).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
setPadding(16, 12, 16, 12)
|
setPadding(res.getDimensionPixelSize(R.dimen.card_padding_horizontal), res.getDimensionPixelSize(R.dimen.card_padding_vertical), res.getDimensionPixelSize(R.dimen.card_padding_horizontal), res.getDimensionPixelSize(R.dimen.card_padding_vertical))
|
||||||
}
|
}
|
||||||
val cb = CheckBox(ctx).apply { id = R.id.checkbox }
|
val cb = CheckBox(ctx).apply { id = R.id.checkbox }
|
||||||
val tv = TextView(ctx).apply {
|
val tv = TextView(ctx).apply {
|
||||||
id = R.id.appName
|
id = R.id.appName
|
||||||
setPadding(16, 0, 0, 0)
|
setPadding(res.getDimensionPixelSize(R.dimen.card_padding_horizontal), 0, 0, 0)
|
||||||
textSize = 15f
|
setTextSize(TypedValue.COMPLEX_UNIT_PX, res.getDimension(R.dimen.list_item_text_size))
|
||||||
setTextColor(
|
setTextColor(
|
||||||
MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, 0)
|
MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, 0)
|
||||||
)
|
)
|
||||||
@@ -62,12 +64,12 @@ class PackageListAdapter(
|
|||||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
val app = apps[position]
|
val app = apps[position]
|
||||||
// Prefer app name (label), fall back to package name
|
// Prefer app name (label), fall back to package name
|
||||||
holder.textView.text = app.label.ifEmpty { app.packageName }
|
holder.textView.text = app.label.ifEmpty { app.packageName.value }
|
||||||
// Avoid re-triggering listener during bind
|
// Avoid re-triggering listener during bind
|
||||||
holder.checkbox.setOnCheckedChangeListener(null)
|
holder.checkbox.setOnCheckedChangeListener(null)
|
||||||
holder.checkbox.isChecked = app.packageName in selected
|
holder.checkbox.isChecked = app.packageName.value in selected
|
||||||
holder.checkbox.setOnCheckedChangeListener { _, checked ->
|
holder.checkbox.setOnCheckedChangeListener { _, checked ->
|
||||||
onToggle(app.packageName, checked)
|
onToggle(app.packageName.value, checked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import android.app.AlertDialog
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import com.example.androidbackupgui.backup.AppInfo
|
import com.example.androidbackupgui.backup.AppInfo
|
||||||
|
import com.example.androidbackupgui.backup.PackageName
|
||||||
import com.example.androidbackupgui.backup.AppScanner
|
import com.example.androidbackupgui.backup.AppScanner
|
||||||
import com.example.androidbackupgui.backup.BackupConfig
|
import com.example.androidbackupgui.backup.BackupConfig
|
||||||
import com.example.androidbackupgui.backup.RestoreOperation
|
import com.example.androidbackupgui.backup.RestoreOperation
|
||||||
@@ -37,6 +38,7 @@ class RestoreFragment : Fragment() {
|
|||||||
private var selectedPackages = mutableSetOf<String>()
|
private var selectedPackages = mutableSetOf<String>()
|
||||||
private var resticConfig: BackupConfig? = null
|
private var resticConfig: BackupConfig? = null
|
||||||
private var selectedSnapshot: ResticWrapper.ResticSnapshot? = null
|
private var selectedSnapshot: ResticWrapper.ResticSnapshot? = null
|
||||||
|
private var resticConfigFingerprint: String? = null
|
||||||
private var selectedUserId: Int = 0
|
private var selectedUserId: Int = 0
|
||||||
private var userList: List<Pair<Int, String>> = listOf(0 to "Owner")
|
private var userList: List<Pair<Int, String>> = listOf(0 to "Owner")
|
||||||
|
|
||||||
@@ -97,7 +99,28 @@ class RestoreFragment : Fragment() {
|
|||||||
// Re-read config so changes from ConfigFragment take effect immediately
|
// Re-read config so changes from ConfigFragment take effect immediately
|
||||||
val configFile = File(requireContext().filesDir, "backup_settings.conf")
|
val configFile = File(requireContext().filesDir, "backup_settings.conf")
|
||||||
val config = BackupConfig.fromFile(configFile)
|
val config = BackupConfig.fromFile(configFile)
|
||||||
|
|
||||||
|
// Detect restic config change — clear stale state if repo/backend changed
|
||||||
|
val newFingerprint = "${config.resticRepo}|${config.resticBackend}|${config.resticBackendUrl}"
|
||||||
|
if (resticConfigFingerprint != null && resticConfigFingerprint != newFingerprint) {
|
||||||
|
selectedSnapshot = null
|
||||||
|
packages = emptyList()
|
||||||
|
selectedPackages.clear()
|
||||||
|
binding.backupDirText.text = ""
|
||||||
|
binding.restoreButton.isEnabled = false
|
||||||
|
binding.selectResticButton.visibility = View.GONE
|
||||||
|
}
|
||||||
|
resticConfigFingerprint = newFingerprint
|
||||||
|
|
||||||
resticConfig = if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) config else null
|
resticConfig = if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) config else null
|
||||||
|
// Skip redundant preparation if binary and backend config are already set
|
||||||
|
if (resticConfig != null &&
|
||||||
|
ResticWrapper.binaryPath.isNotEmpty() &&
|
||||||
|
ResticWrapper.binaryPath != "restic" &&
|
||||||
|
ResticWrapper.backendDomain == config.resticBackendDomain
|
||||||
|
) {
|
||||||
|
binding.selectResticButton.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
val binaryPath = ResticBinary.prepare(requireContext())
|
val binaryPath = ResticBinary.prepare(requireContext())
|
||||||
if (binaryPath != null && resticConfig != null) {
|
if (binaryPath != null && resticConfig != null) {
|
||||||
ResticWrapper.binaryPath = binaryPath
|
ResticWrapper.binaryPath = binaryPath
|
||||||
@@ -106,6 +129,7 @@ class RestoreFragment : Fragment() {
|
|||||||
binding.selectResticButton.visibility = View.VISIBLE
|
binding.selectResticButton.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun selectBackupDir() {
|
private fun selectBackupDir() {
|
||||||
val defaultDir = File(requireContext().filesDir.absolutePath)
|
val defaultDir = File(requireContext().filesDir.absolutePath)
|
||||||
@@ -143,7 +167,7 @@ class RestoreFragment : Fragment() {
|
|||||||
binding.statusText.text = "共 ${packages.size} 个备份应用"
|
binding.statusText.text = "共 ${packages.size} 个备份应用"
|
||||||
binding.restoreButton.isEnabled = packages.isNotEmpty()
|
binding.restoreButton.isEnabled = packages.isNotEmpty()
|
||||||
|
|
||||||
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = it) })
|
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = PackageName(it)) })
|
||||||
setupAppList()
|
setupAppList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,21 +185,21 @@ class RestoreFragment : Fragment() {
|
|||||||
backendPass = config.resticBackendPass,
|
backendPass = config.resticBackendPass,
|
||||||
backendShare = config.resticBackendShare,
|
backendShare = config.resticBackendShare,
|
||||||
onSyncProgress = { p ->
|
onSyncProgress = { p ->
|
||||||
binding.statusText.text = "同步中: ${p.current}/${p.total} [${p.currentFile}]"
|
updateStatus("同步中: ${p.current}/${p.total} [${p.currentFile}]")
|
||||||
},
|
},
|
||||||
onByteSyncProgress = { bp ->
|
onByteSyncProgress = { bp ->
|
||||||
binding.statusText.text = "下载中: ${bp.bytesTransferred / 1024 / 1024} MB / ${bp.totalBytes / 1024 / 1024} MB"
|
updateStatus("下载中: ${bp.bytesTransferred / 1024 / 1024} MB / ${bp.totalBytes / 1024 / 1024} MB")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (snapshotsResult.isFailure) {
|
if (snapshotsResult.isFailure) {
|
||||||
binding.statusText.text = "读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}"
|
updateStatus("读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}")
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val snapshots = snapshotsResult.getOrThrow()
|
val snapshots = snapshotsResult.getOrThrow()
|
||||||
if (snapshots.isEmpty()) {
|
if (snapshots.isEmpty()) {
|
||||||
binding.statusText.text = "没有可用的 restic 快照"
|
updateStatus("没有可用的 restic 快照")
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -185,7 +209,7 @@ class RestoreFragment : Fragment() {
|
|||||||
snapshots.first()
|
snapshots.first()
|
||||||
} else {
|
} else {
|
||||||
pickSnapshot(snapshots) ?: run {
|
pickSnapshot(snapshots) ?: run {
|
||||||
binding.statusText.text = "已取消选择"
|
updateStatus("已取消选择")
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -195,7 +219,7 @@ class RestoreFragment : Fragment() {
|
|||||||
backupDir = null
|
backupDir = null
|
||||||
selectedSnapshot = chosenSnapshot
|
selectedSnapshot = chosenSnapshot
|
||||||
val backupPath = selectedSnapshot!!.paths.firstOrNull() ?: run {
|
val backupPath = selectedSnapshot!!.paths.firstOrNull() ?: run {
|
||||||
binding.statusText.text = "快照中找不到备份路径"
|
updateStatus("快照中找不到备份路径")
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -209,7 +233,7 @@ class RestoreFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (packages.isEmpty()) {
|
if (packages.isEmpty()) {
|
||||||
binding.statusText.text = "无法从快照读取应用列表"
|
updateStatus("无法从快照读取应用列表")
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -220,9 +244,9 @@ class RestoreFragment : Fragment() {
|
|||||||
selectedPackages.addAll(packages)
|
selectedPackages.addAll(packages)
|
||||||
|
|
||||||
// Resolve app labels for display
|
// Resolve app labels for display
|
||||||
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = it) })
|
appInfos = AppScanner.resolveLabels(requireContext(), packages.map { AppInfo(packageName = PackageName(it)) })
|
||||||
|
|
||||||
binding.statusText.text = "restic 快照共 ${packages.size} 个应用,点击恢复开始"
|
updateStatus("restic 快照共 ${packages.size} 个应用,点击恢复开始")
|
||||||
binding.restoreButton.isEnabled = true
|
binding.restoreButton.isEnabled = true
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
setupAppList()
|
setupAppList()
|
||||||
@@ -385,6 +409,10 @@ class RestoreFragment : Fragment() {
|
|||||||
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
|
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun updateStatus(text: String) {
|
||||||
|
binding.statusText.text = text
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
_binding = null
|
_binding = null
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="16dp"
|
android:padding="@dimen/fragment_horizontal_padding"
|
||||||
android:background="?attr/colorSurface">
|
android:background="?attr/colorSurface">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
@@ -62,9 +62,9 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginEnd="4dp"
|
android:layout_marginEnd="2dp"
|
||||||
android:text="A-Z"
|
android:text="A-Z"
|
||||||
android:textSize="12sp"
|
android:textSize="11sp"
|
||||||
style="@style/Widget.Material3.Button.TonalButton" />
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
@@ -72,9 +72,10 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginStart="4dp"
|
android:layout_marginStart="2dp"
|
||||||
|
android:layout_marginEnd="2dp"
|
||||||
android:text="大小"
|
android:text="大小"
|
||||||
android:textSize="12sp"
|
android:textSize="11sp"
|
||||||
style="@style/Widget.Material3.Button.TonalButton" />
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
@@ -82,9 +83,10 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginStart="4dp"
|
android:layout_marginStart="2dp"
|
||||||
|
android:layout_marginEnd="2dp"
|
||||||
android:text="全选"
|
android:text="全选"
|
||||||
android:textSize="12sp"
|
android:textSize="11sp"
|
||||||
style="@style/Widget.Material3.Button.TonalButton" />
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
@@ -92,9 +94,9 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginStart="4dp"
|
android:layout_marginStart="2dp"
|
||||||
android:text="取消全选"
|
android:text="取消全选"
|
||||||
android:textSize="12sp"
|
android:textSize="11sp"
|
||||||
style="@style/Widget.Material3.Button.TonalButton" />
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@@ -129,6 +131,8 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
|
android:maxLines="3"
|
||||||
|
android:ellipsize="end"
|
||||||
android:text="点击扫描以载入应用列表"
|
android:text="点击扫描以载入应用列表"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="?attr/colorSurface"
|
android:background="?attr/colorSurface"
|
||||||
android:clipToPadding="false"
|
android:clipToPadding="false"
|
||||||
android:padding="16dp">
|
android:padding="@dimen/fragment_horizontal_padding">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -162,47 +162,53 @@
|
|||||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
android:textColor="?attr/colorOnSurface" />
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButtonToggleGroup
|
<HorizontalScrollView
|
||||||
android:id="@+id/resticBackendGroup"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="4dp"
|
android:layout_marginTop="4dp"
|
||||||
|
android:scrollbars="none">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButtonToggleGroup
|
||||||
|
android:id="@+id/resticBackendGroup"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
app:singleSelection="true"
|
app:singleSelection="true"
|
||||||
app:selectionRequired="true">
|
app:selectionRequired="true">
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/resticBackendLocal"
|
android:id="@+id/resticBackendLocal"
|
||||||
android:layout_width="0dp"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:minWidth="80dp"
|
||||||
android:text="本机"
|
android:text="本机"
|
||||||
style="@style/Widget.Material3.Button.TonalButton" />
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/resticBackendWebdav"
|
android:id="@+id/resticBackendWebdav"
|
||||||
android:layout_width="0dp"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:minWidth="80dp"
|
||||||
android:text="WebDAV"
|
android:text="WebDAV"
|
||||||
style="@style/Widget.Material3.Button.TonalButton" />
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/resticBackendSmb"
|
android:id="@+id/resticBackendSmb"
|
||||||
android:layout_width="0dp"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:minWidth="80dp"
|
||||||
android:text="SMB"
|
android:text="SMB"
|
||||||
style="@style/Widget.Material3.Button.TonalButton" />
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/resticBackendRestServer"
|
android:id="@+id/resticBackendRestServer"
|
||||||
android:layout_width="0dp"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:minWidth="80dp"
|
||||||
android:text="REST"
|
android:text="REST"
|
||||||
style="@style/Widget.Material3.Button.TonalButton" />
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||||
|
</HorizontalScrollView>
|
||||||
|
|
||||||
<!-- Backend URL (WebDAV/SMB only) -->
|
<!-- Backend URL (WebDAV/SMB only) -->
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="16dp"
|
android:padding="@dimen/fragment_horizontal_padding"
|
||||||
android:background="?attr/colorSurface">
|
android:background="?attr/colorSurface">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
@@ -85,6 +85,8 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
|
android:maxLines="3"
|
||||||
|
android:ellipsize="end"
|
||||||
android:text="请先选择备份文件夹"
|
android:text="请先选择备份文件夹"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|||||||
@@ -46,6 +46,8 @@
|
|||||||
<item name="colorSurfaceContainerHigh">@color/surfaceContainerHigh</item>
|
<item name="colorSurfaceContainerHigh">@color/surfaceContainerHigh</item>
|
||||||
<item name="colorSurfaceContainerHighest">@color/surfaceContainerHighest</item>
|
<item name="colorSurfaceContainerHighest">@color/surfaceContainerHighest</item>
|
||||||
|
|
||||||
|
<!-- Display cutout: allow content under punch-hole/notch, inset listener handles padding -->
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">always</item>
|
||||||
<!-- Status bar — dark theme -->
|
<!-- Status bar — dark theme -->
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
|||||||
17
app/src/main/res/values-sw600dp/dimens.xml
Normal file
17
app/src/main/res/values-sw600dp/dimens.xml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Card dimensions (tablet: wider layout, larger touch targets) -->
|
||||||
|
<dimen name="card_padding_horizontal">24dp</dimen>
|
||||||
|
<dimen name="card_padding_vertical">16dp</dimen>
|
||||||
|
<dimen name="card_radius">16dp</dimen>
|
||||||
|
<dimen name="card_margin_bottom">12dp</dimen>
|
||||||
|
|
||||||
|
<!-- List item text size -->
|
||||||
|
<dimen name="list_item_text_size">18sp</dimen>
|
||||||
|
|
||||||
|
<!-- Fragment layout padding -->
|
||||||
|
<dimen name="fragment_horizontal_padding">24dp</dimen>
|
||||||
|
|
||||||
|
<!-- Bottom navigation: inset padding from system bars (set dynamically) -->
|
||||||
|
<dimen name="bottom_nav_padding_bottom">0dp</dimen>
|
||||||
|
</resources>
|
||||||
17
app/src/main/res/values/dimens.xml
Normal file
17
app/src/main/res/values/dimens.xml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Card dimensions (phone baseline) -->
|
||||||
|
<dimen name="card_padding_horizontal">16dp</dimen>
|
||||||
|
<dimen name="card_padding_vertical">12dp</dimen>
|
||||||
|
<dimen name="card_radius">12dp</dimen>
|
||||||
|
<dimen name="card_margin_bottom">8dp</dimen>
|
||||||
|
|
||||||
|
<!-- List item text size -->
|
||||||
|
<dimen name="list_item_text_size">15sp</dimen>
|
||||||
|
|
||||||
|
<!-- Fragment layout padding -->
|
||||||
|
<dimen name="fragment_horizontal_padding">16dp</dimen>
|
||||||
|
|
||||||
|
<!-- Bottom navigation: inset padding from system bars (set dynamically) -->
|
||||||
|
<dimen name="bottom_nav_padding_bottom">0dp</dimen>
|
||||||
|
</resources>
|
||||||
@@ -47,6 +47,8 @@
|
|||||||
<item name="colorSurfaceContainerHigh">@color/surfaceContainerHigh</item>
|
<item name="colorSurfaceContainerHigh">@color/surfaceContainerHigh</item>
|
||||||
<item name="colorSurfaceContainerHighest">@color/surfaceContainerHighest</item>
|
<item name="colorSurfaceContainerHighest">@color/surfaceContainerHighest</item>
|
||||||
|
|
||||||
|
<!-- Display cutout: allow content under punch-hole/notch, inset listener handles padding -->
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">always</item>
|
||||||
<!-- Status bar -->
|
<!-- Status bar -->
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ buildscript {
|
|||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
|
classpath "org.jetbrains.kotlinx:kover-gradle-plugin:0.9.8"
|
||||||
classpath 'com.android.tools.build:gradle:8.2.0'
|
classpath 'com.android.tools.build:gradle:8.2.0'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
|
||||||
|
|||||||
701
docs/superpowers/plans/2026-06-02-android-backup-optimization.md
Normal file
701
docs/superpowers/plans/2026-06-02-android-backup-optimization.md
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
# Android Backup GUI — 代码优化实施计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development (recommended) or executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** 对 Android Backup GUI 进行三项高影响优化:类型化错误处理、协程/Flow 重构、安全加固,外加 Kotlin 惯用清理。
|
||||||
|
|
||||||
|
**Architecture:** 项目结构为 app/src/main/java/com/example/androidbackupgui/{backup,ui,root} 三层。backup 层 22 个文件平铺,无 domain 层。优化采用增量替换模式——不重构包结构,只在现有边界内替换实现。
|
||||||
|
|
||||||
|
**Tech Stack:** Kotlin + Coroutines + StateFlow + DataBinding + libsu (root) + sardine-android (WebDAV) + jcifs-ng (SMB)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 0: 基础准备
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/src/main/java/com/example/androidbackupgui/backup/AppError.kt`
|
||||||
|
- Create: `app/src/main/java/com/example/androidbackupgui/backup/TransferProgress.kt`
|
||||||
|
- Test: (暂无测试框架,先创建接口不破坏编译)
|
||||||
|
|
||||||
|
- [ ] **创建 sealed class 错误层次**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// app/src/main/java/com/example/androidbackupgui/backup/AppError.kt
|
||||||
|
package com.example.androidbackupgui.backup
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类型化应用错误层次。所有业务层错误统一为此 sealed interface。
|
||||||
|
*/
|
||||||
|
sealed interface AppError {
|
||||||
|
|
||||||
|
/** 人类可读的错误描述 */
|
||||||
|
val message: String
|
||||||
|
|
||||||
|
/** 网络/IO 类错误 */
|
||||||
|
data class Network(
|
||||||
|
override val message: String,
|
||||||
|
val cause: Throwable? = null,
|
||||||
|
val retryable: Boolean = true
|
||||||
|
) : AppError
|
||||||
|
|
||||||
|
/** Root shell 命令执行错误 */
|
||||||
|
data class Shell(
|
||||||
|
override val message: String,
|
||||||
|
val command: String,
|
||||||
|
val exitCode: Int,
|
||||||
|
val stderr: String
|
||||||
|
) : AppError
|
||||||
|
|
||||||
|
/** 远端文件操作错误(WebDAV/SMB) */
|
||||||
|
data class Remote(
|
||||||
|
override val message: String,
|
||||||
|
val phase: String,
|
||||||
|
val cause: Throwable? = null,
|
||||||
|
val isNotFound: Boolean = false,
|
||||||
|
val retryable: Boolean = false
|
||||||
|
) : AppError
|
||||||
|
|
||||||
|
/** 本地文件/IO 错误 */
|
||||||
|
data class LocalIO(
|
||||||
|
override val message: String,
|
||||||
|
val path: String,
|
||||||
|
val cause: Throwable? = null
|
||||||
|
) : AppError
|
||||||
|
|
||||||
|
/** restic 命令执行错误 */
|
||||||
|
data class Restic(
|
||||||
|
override val message: String,
|
||||||
|
val exitCode: Int,
|
||||||
|
val stderr: String
|
||||||
|
) : AppError
|
||||||
|
|
||||||
|
/** 解析/配置错误 */
|
||||||
|
data class Parse(
|
||||||
|
override val message: String,
|
||||||
|
val detail: String = ""
|
||||||
|
) : AppError
|
||||||
|
|
||||||
|
/** 操作被取消 */
|
||||||
|
data object Cancelled : AppError {
|
||||||
|
override val message: String = "操作被取消"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **验证编译通过**
|
||||||
|
|
||||||
|
Run: `./gradlew assembleDebug 2>&1 | tail -20`
|
||||||
|
Expected: BUILD SUCCESSFUL
|
||||||
|
|
||||||
|
- [ ] **创建 AppResult 类型别名**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 在 AppError.kt 末尾追加
|
||||||
|
typealias AppResult<T> = Result<T>
|
||||||
|
// 后续步骤逐步替换为自定义 sealed Result 类型
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 类型化错误处理 — RemoteTransport 层
|
||||||
|
|
||||||
|
**目标:** 将 `RemoteTransport` 接口和实现中的 `Result.failure(Exception(...))` 替换为 `AppError`,消除字符串拼接异常和沉默吞错误。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/RemoteTransport.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/WebdavTransport.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/SmbTransport.kt`
|
||||||
|
- Delete: (删除 `FileNotFoundException` 类,被 `AppError.Remote(isNotFound=true)` 替代)
|
||||||
|
|
||||||
|
- [ ] **替换 RemoteTransport 返回类型**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// RemoteTransport.kt — 接口方法签名替换
|
||||||
|
// 原来: suspend fun upload(...): Result<Unit>
|
||||||
|
// → suspend fun upload(...): AppResult<Unit>
|
||||||
|
// 原来: suspend fun listFiles(...): Result<List<RemoteFileInfo>>
|
||||||
|
// → suspend fun listFiles(...): AppResult<List<RemoteFileInfo>>
|
||||||
|
// 原来: suspend fun exists(...): Result<Boolean>
|
||||||
|
// → suspend fun exists(...): AppResult<Boolean>
|
||||||
|
// 原来: class FileNotFoundException(path: String) : Exception("Directory not found: $path")
|
||||||
|
// → 删除整个类
|
||||||
|
|
||||||
|
// Result 保持 kotlin.Result 作为 AppResult,但创建 err 辅助函数
|
||||||
|
// RemoteTransport.kt 末尾追加
|
||||||
|
internal fun <T> err(error: AppError): AppResult<T> =
|
||||||
|
Result.failure(RuntimeException(error.message).also { /* AppError marker — 后续步骤用 sealed result 替换 */ })
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **替换 WebdavTransport.upload — 使用 AppError**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// WebdavTransport.kt — upload 方法
|
||||||
|
override suspend fun upload(...): AppResult<Unit> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
// ... 文件大小检查
|
||||||
|
if (fileSize > 50 * 1024 * 1024L) {
|
||||||
|
return@withContext err(
|
||||||
|
AppError.LocalIO("文件过大 (${fileSize / 1024 / 1024}MB),上限 50MB", localPath)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// ... 传输逻辑
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "upload failed: $remotePath", e)
|
||||||
|
err(AppError.Remote("WebDAV 上传失败", "upload", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **替换 WebdavTransport.download**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// WebdavTransport.kt — download 方法 catch 块
|
||||||
|
// 原来: return@withContext Result.failure(Exception("WebDAV download failed: ${e.message}", e))
|
||||||
|
// → return@withContext err(AppError.Remote("WebDAV 下载失败", "download", e))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **替换 WebdavTransport.listFiles — 区分 404 和真实错误**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// WebdavTransport.kt — listFiles 方法
|
||||||
|
// 原来: return@withContext Result.failure(FileNotFoundException(remoteDir))
|
||||||
|
// → return@withContext err(AppError.Remote("远端路径不存在", "list", isNotFound = true))
|
||||||
|
// 原来: return@withContext Result.failure(Exception("WebDAV list failed: ${e.message}", e))
|
||||||
|
// → return@withContext err(AppError.Remote("WebDAV 列表失败: ${e.message}", "list", e))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **替换 WebdavTransport.mkdirs / delete / exists**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// mkdirs: 内部 catch 不做错误传播,保持 Result.success(Unit) 最佳努力模式
|
||||||
|
// delete: 内部 catch 保持 Result.success(Unit) 沉默处理
|
||||||
|
// 这两个方法是显式的"尽力而为"语义,保持现状但添加注释说明
|
||||||
|
|
||||||
|
// exists: 原来 return@withContext Result.failure(Exception("WebDAV exists check failed: ${e.message}", e))
|
||||||
|
// → return@withContext err(AppError.Remote("检查远端路径失败", "exists", e))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **替换 SmbTransport.kt 同样的模式**
|
||||||
|
|
||||||
|
搜索 `SmbTransport.kt` 中所有 `Result.failure(Exception(` 和 `FileNotFoundException(` 的出现,按 WebDAV 相同规则替换。
|
||||||
|
|
||||||
|
Run: `./gradlew assembleDebug 2>&1 | tail -20`
|
||||||
|
Expected: BUILD SUCCESSFUL
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "refactor: replace raw Exception with typed AppError in RemoteTransport layer"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 类型化错误处理 — ResticWrapper 及调用方
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticWrapper.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticBackup.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticRestore.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticRepoInit.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticSnapshotOps.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticMaintenance.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticCommandRunner.kt`
|
||||||
|
|
||||||
|
- [ ] **替换 ResticCommandRunner 异常处理**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// ResticCommandRunner.kt — runRestic 方法
|
||||||
|
// catch 块原来:
|
||||||
|
// CommandResult("", e.message ?: "Unknown error", -1)
|
||||||
|
// 改为带日志区分:
|
||||||
|
// — IOException → 网络/IO 错误
|
||||||
|
// — InterruptedIOException → 超时/取消
|
||||||
|
// — 其他 → 通用错误
|
||||||
|
// 方法签名不变(CommandResult 是内部数据类),但 Log.e 带上 cause
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **替换 ResticBackup.parseBackupSummary — 字符串异常 → AppError**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// ResticBackup.kt — parseBackupSummary 方法
|
||||||
|
// 原来: return Result.failure(Exception("No summary found in restic output"))
|
||||||
|
// → return Result.failure(
|
||||||
|
// RuntimeException(AppError.Restic("未在 restic 输出中找到 summary", -1, stdout.take(200)).toString())
|
||||||
|
// ).also { Log.w(TAG, "parseBackupSummary: no summary in ${stdout.length} chars") }
|
||||||
|
|
||||||
|
// 原来 catch (_: Exception) 两种用法:
|
||||||
|
// — progress 解析失败: 保持沉默(非 JSON 行是正常的)
|
||||||
|
// — summary 解析失败: 加 Log.w
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **替换 ResticBackup.backup — 异常传递**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// ResticBackup.kt — backup 方法
|
||||||
|
// 原来: return@withRemoteSync Result.failure(Exception("restic backup failed: ${result.stderr}"))
|
||||||
|
// → return@withRemoteSync Result.failure(
|
||||||
|
// RuntimeException(AppError.Restic("restic backup 失败", result.exitCode, result.stderr).toString())
|
||||||
|
// )
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **对其他 Restic* 类执行相同替换**
|
||||||
|
|
||||||
|
搜索 `Result.failure(Exception(` 和 `Result.failure(RuntimeException(` 在所有 `Restic*.kt` 中的出现。每条替换为带 `AppError.Restic` 或 `AppError.LocalIO` 的形式。
|
||||||
|
|
||||||
|
Run: `./gradlew assembleDebug 2>&1 | tail -20`
|
||||||
|
Expected: BUILD SUCCESSFUL
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -a -m "refactor: add typed AppError to Restic* command results"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: 协程优化 — 进度回调改为 Flow
|
||||||
|
|
||||||
|
**问题:** `onProgress: suspend (T) -> Unit` 回调穿过 5+ 层方法签名,每个回调内部 `withContext(Dispatchers.Main)` 切换线程。8KB 粒度的 `ByteProgress` 导致频繁 Context 切换。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/src/main/java/com/example/androidbackupgui/backup/TransferProgress.kt` (从 RemoteTransport 提取)
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/RemoteTransport.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/WebdavTransport.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/SmbTransport.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/RemoteSyncManager.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticBackup.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticWrapper.kt`
|
||||||
|
|
||||||
|
- [ ] **提取进度类型到独立文件**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// app/src/main/java/com/example/androidbackupgui/backup/TransferProgress.kt
|
||||||
|
package com.example.androidbackupgui.backup
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
||||||
|
/** 传输阶段进度(连接/传输/完成等) */
|
||||||
|
@Serializable
|
||||||
|
data class TransferProgress(
|
||||||
|
val phase: String,
|
||||||
|
val current: Int,
|
||||||
|
val total: Int,
|
||||||
|
val currentFile: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 字节粒度传输进度 */
|
||||||
|
@Serializable
|
||||||
|
data class ByteProgress(
|
||||||
|
val bytesTransferred: Long,
|
||||||
|
val totalBytes: Long,
|
||||||
|
val currentFile: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 合并的传输进度事件流 */
|
||||||
|
sealed interface TransferEvent {
|
||||||
|
data class Phase(val progress: TransferProgress) : TransferEvent
|
||||||
|
data class Bytes(val progress: ByteProgress) : TransferEvent
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **简化 RemoteTransport 接口 — 用 Flow 替换回调对**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// RemoteTransport.kt — upload/download 签名替换
|
||||||
|
|
||||||
|
// 原来:
|
||||||
|
// suspend fun upload(..., onProgress: suspend (TransferProgress) -> Unit = {}, onByteProgress: suspend (ByteProgress) -> Unit = {}): Result<Unit>
|
||||||
|
// → suspend fun upload(..., onProgress: FlowCollector<TransferEvent>? = null): AppResult<Unit>
|
||||||
|
//
|
||||||
|
// 但为了与当前调用方兼容,改用 SharedFlow 模式:
|
||||||
|
// 保持 suspend fun upload(...): AppResult<Unit>
|
||||||
|
// 创建一个挂起辅助函数,返回 Flow<TransferEvent>
|
||||||
|
|
||||||
|
// 新增扩展方法:
|
||||||
|
suspend fun RemoteTransport.uploadWithFlow(
|
||||||
|
localPath: String,
|
||||||
|
remotePath: String
|
||||||
|
): Flow<TransferEvent> = flow {
|
||||||
|
val result = upload(
|
||||||
|
localPath, remotePath,
|
||||||
|
onProgress = { p -> emit(TransferEvent.Phase(p)) },
|
||||||
|
onByteProgress = { b -> emit(TransferEvent.Bytes(b)) }
|
||||||
|
)
|
||||||
|
// 结果在 flow 完成后通过单独 result 获取
|
||||||
|
}.flowOn(Dispatchers.IO)
|
||||||
|
|
||||||
|
// 但更实用的方式:将 emit 直接传入 upload 内部
|
||||||
|
// 方案:upload 内部发射到 FlowCollector,而不是回调参数
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **简化方案:只在调用方优化线程切换**
|
||||||
|
|
||||||
|
当前最痛的点是 `RemoteSyncManager.withRemoteSync` 内部的 `withContext(Dispatchers.Main)` 每次回调都切换。
|
||||||
|
|
||||||
|
**改为:channel + 批量投递到 Main**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 在 withRemoteSync 内部:
|
||||||
|
// 原来:
|
||||||
|
// val emitProgress: suspend (TransferProgress) -> Unit = { p ->
|
||||||
|
// withContext(Dispatchers.Main) { onProgress(p) }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// 改为:
|
||||||
|
// val progressChannel = Channel<TransferEvent>(Channel.CONFLATED)
|
||||||
|
// val progressJob = launch(Dispatchers.Main) {
|
||||||
|
// for (event in progressChannel) {
|
||||||
|
// when (event) {
|
||||||
|
// is TransferEvent.Phase -> onProgress(event.progress)
|
||||||
|
// is TransferEvent.Bytes -> {
|
||||||
|
// // 限制 ByteProgress 投递频率: 每 50ms 投递一次
|
||||||
|
// val now = System.currentTimeMillis()
|
||||||
|
// if (now - lastByteEmitMs >= 50) {
|
||||||
|
// onByteProgress(event.progress)
|
||||||
|
// lastByteEmitMs = now
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
```
|
||||||
|
|
||||||
|
不需要修改 RemoteTransport 接口,只修改 `RemoteSyncManager.withRemoteSync` 内部的回调包装方式。
|
||||||
|
|
||||||
|
- [ ] **重构 withRemoteSync 内部使用 Channel**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// RemoteSyncManager.kt
|
||||||
|
// 修改 withRemoteSync 方法,在大括号前插入:
|
||||||
|
|
||||||
|
suspend fun <T> withRemoteSync(
|
||||||
|
// ... 参数不变 ...
|
||||||
|
): Result<T> {
|
||||||
|
if (backend != "smb" && backend != "webdav") return action()
|
||||||
|
|
||||||
|
return repoSyncMutex.withLock {
|
||||||
|
var shouldCleanup = false
|
||||||
|
try {
|
||||||
|
val t = ensureTransport(/*...*/)
|
||||||
|
?: return@withLock Result.failure(Exception("传输创建失败"))
|
||||||
|
|
||||||
|
val localDir = File(tempRepoDir)
|
||||||
|
|
||||||
|
// === 进度回调优化:Channel + Main 协程批量处理 ===
|
||||||
|
var lastByteEmitMs = 0L
|
||||||
|
coroutineScope {
|
||||||
|
val progressChannel = Channel<TransferEvent>(Channel.CONFLATED)
|
||||||
|
val progressJob = launch(Dispatchers.Main) {
|
||||||
|
for (event in progressChannel) {
|
||||||
|
when (event) {
|
||||||
|
is TransferEvent.Phase -> onProgress(event.progress)
|
||||||
|
is TransferEvent.Bytes -> {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (!onByteProgress.isNoop && now - lastByteEmitMs >= 50) {
|
||||||
|
onByteProgress(event.progress)
|
||||||
|
lastByteEmitMs = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 包装 emitProgress
|
||||||
|
val emitProgress: suspend (TransferProgress) -> Unit = { p ->
|
||||||
|
progressChannel.send(TransferEvent.Phase(p))
|
||||||
|
}
|
||||||
|
val emitByteProgress: suspend (ByteProgress) -> Unit = { b ->
|
||||||
|
progressChannel.send(TransferEvent.Bytes(ByteProgress(b.bytesTransferred, b.totalBytes, b.currentFile)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... 原有 sync/action 逻辑,用 emitProgress 和 emitByteProgress ...
|
||||||
|
// 注意原代码的 action() 是同步调用,需要包在 coroutineScope 内
|
||||||
|
}
|
||||||
|
// ... 后续逻辑 ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **验证编译通过并运行基本功能**
|
||||||
|
|
||||||
|
Run: `./gradlew assembleDebug`
|
||||||
|
Expected: BUILD SUCCESSFUL
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -a -m "perf: batch Main-thread progress emits via CONFLATED Channel with 50ms throttle"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 协程优化 — 结构化并发与取消
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/RootShell.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/ui/ConfigViewModel.kt`
|
||||||
|
|
||||||
|
- [ ] **BackupOperation.backupApps — 确保协程取消传播**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// BackupOperation.kt — backupApps 方法
|
||||||
|
// 该方法使用 withContext(Dispatchers.IO) + Semaphore + 内部的 launch
|
||||||
|
// 问题: launch 在 withContext 内启动,如果不持有 Job 句柄,取消无法传播
|
||||||
|
|
||||||
|
// 修改: 用 coroutineScope 代替裸 launch
|
||||||
|
// 原来:
|
||||||
|
// launch {
|
||||||
|
// semaphore.withPermit {
|
||||||
|
// backupSingleApp(...)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// → coroutineScope {
|
||||||
|
// launch {
|
||||||
|
// semaphore.withPermit {
|
||||||
|
// backupSingleApp(...)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 更优: 用 map + async + Semaphore 替代 launch 集合
|
||||||
|
val deferreds = apps.map { app ->
|
||||||
|
async(backupSemaphore.asContextElement()) {
|
||||||
|
backupSingleApp(context, app, config, outputDir, userId, onProgress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val results = deferreds.awaitAll()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **RootShell.exec — 使用 ensureActive 替代被动超时**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// RootShell.kt — exec 方法
|
||||||
|
// 当前: 靠 withTimeout(120s) 兜底
|
||||||
|
// 在等待过程中添加 ensureActive 检查
|
||||||
|
|
||||||
|
// 在多条命令场景(如备份数据)添加:
|
||||||
|
// ensureActive() // 在 runTar 循环内部
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **ConfigViewModel — 使用 WhileSubscribed 替代 WhileStarted**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// ConfigViewModel.kt
|
||||||
|
// 当前可能使用 stateIn(WhileSubscribed(0)) 或默认
|
||||||
|
// 改为 WhileSubscribed(5000) 保证配置变更存活 5 秒
|
||||||
|
// 具体取决于当前代码
|
||||||
|
|
||||||
|
// 检查当前 SharingStarted 模式并优化
|
||||||
|
// 如果已经是 WhileSubscribed(5000),跳过
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `./gradlew assembleDebug`
|
||||||
|
Expected: BUILD SUCCESSFUL
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -a -m "refactor: ensure structured concurrency in BackupOperation and cancellation propagation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: 安全加固 — Root shell 注入防护
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/root/RootShell.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/WifiManager.kt`
|
||||||
|
|
||||||
|
- [ ] **审计所有 RootShell.exec 调用方**
|
||||||
|
|
||||||
|
用搜索找到所有 `RootShell.exec(` 或 `RootShell.exec("` 调用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 搜索所有 root shell 调用
|
||||||
|
# 在项目中搜索 RootShell.exec
|
||||||
|
```
|
||||||
|
|
||||||
|
当前已知的 root shell 调用点:
|
||||||
|
1. `WifiManager.kt`: `cp '$wifiSource' '${wifiDest.absolutePath.shellEscape()}'` — wifiDest 已 shellEscape,wifiSource 从预定义列表来(安全)
|
||||||
|
2. `BackupOperation.kt`: 多处 `pm path`、`dumpsys package`、`cp`、`tar`、`ls`、`rm` — 输入中 packageName 来自 `AppScanner`(非用户输入,安全),但 file path 拼接需要确认 shellEscape
|
||||||
|
3. `SELinuxUtil.kt`: `restorecon` 命令
|
||||||
|
|
||||||
|
- [ ] **为所有 root shell 参数统一使用 shellEscape 扩展函数**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 当前 shellEscape 已经存在 RootShell.kt 中
|
||||||
|
// 审计每个 RootShell.exec 调用的参数是否穿过了 shellEscape()
|
||||||
|
|
||||||
|
// 在 BackupOperation.runTar 中:
|
||||||
|
// 当前 val cmd = "tar ... '$excludesStr' ..."
|
||||||
|
// 确认 excludes 路径都经过了 shellEscape
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **创建 RootShell.exec 安全包装**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// RootShell.kt — 添加安全执行方法
|
||||||
|
// 禁止直接 exec 字符串拼接;提供 vararg 参数形式
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全执行 root shell 命令,自动转义参数。
|
||||||
|
* @param commandFmt 命令格式,用 {N} 占位(而非 $N 避免 shell 解析)
|
||||||
|
* @param args 参数列表,自动 shellEscape
|
||||||
|
*/
|
||||||
|
suspend fun execSafe(
|
||||||
|
commandParts: List<String>,
|
||||||
|
timeoutMs: Long = COMMAND_TIMEOUT_MS
|
||||||
|
): ShellResult = withContext(Dispatchers.IO) {
|
||||||
|
val command = commandParts.joinToString(" ")
|
||||||
|
exec(command, timeoutMs)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **审计 restic 密码传递路径**
|
||||||
|
|
||||||
|
密码通过 `ResticEnvResolver.buildFullEnv` 设置到环境变量 `RESTIC_PASSWORD`。ProcessBuilder 环境变量对其他进程不可见,检查是否被 logging 记录:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// ResticCommandRunner.kt — 检查 Log.d 是否泄露密码
|
||||||
|
// 当前: Log.d(TAG, "runRestic REPOSITORY=${env["RESTIC_REPOSITORY"]}")
|
||||||
|
// Log.d 不包含 RESTIC_PASSWORD — 安全,但添加注释说明
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `./gradlew assembleDebug`
|
||||||
|
Expected: BUILD SUCCESSFUL
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -a -m "security: audit root shell injection surface and add execSafe helper"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Kotlin 惯用清理
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/BinaryResolver.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticCommandRunner.kt`
|
||||||
|
- Modify: `app/src/main/java/com/example/androidbackupgui/backup/ResticWrapper.kt`
|
||||||
|
|
||||||
|
- [ ] **BinaryResolver — 缓存替换为 by lazy**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// BinaryResolver.kt
|
||||||
|
// 原来: 两个 ResolveCache 对象 + 手动 initialized 标志
|
||||||
|
// 改为 by lazy 委托:
|
||||||
|
|
||||||
|
object BinaryResolver {
|
||||||
|
private const val TAG = "BinaryResolver"
|
||||||
|
|
||||||
|
private fun resolve(context: Context, libName: String, destName: String): String? {
|
||||||
|
val nativeLibDir = context.applicationInfo.nativeLibraryDir
|
||||||
|
val source = File(nativeLibDir, libName)
|
||||||
|
if (!source.isFile) {
|
||||||
|
Log.e(TAG, "$libName not found at ${source.absolutePath}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val dest = File(context.filesDir, "bin/$destName")
|
||||||
|
if (!dest.exists() || dest.length() != source.length() || !dest.canExecute()) {
|
||||||
|
dest.parentFile?.mkdirs()
|
||||||
|
if (dest.exists()) dest.delete()
|
||||||
|
source.inputStream().use { src -> dest.outputStream().use { out -> src.copyTo(out) } }
|
||||||
|
dest.setExecutable(true)
|
||||||
|
}
|
||||||
|
Log.i(TAG, "ready: $libName -> ${dest.absolutePath} (${dest.length()} bytes)")
|
||||||
|
return dest.absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _context = ThreadLocal<Context>()
|
||||||
|
|
||||||
|
/** 在 Application.onCreate 时调用 */
|
||||||
|
fun init(context: Context) { _context.set(context) }
|
||||||
|
|
||||||
|
val tarPath: String? by lazy {
|
||||||
|
_context.get()?.let { resolve(it, "libtar_bin.so", "tar_bin") }
|
||||||
|
}
|
||||||
|
val zstdPath: String? by lazy {
|
||||||
|
_context.get()?.let { resolve(it, "libzstd_bin.so", "zstd_bin") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **ResticCommandRunner.buildCommandArgs — 表达式函数**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// ResticCommandRunner.kt
|
||||||
|
// 原来:
|
||||||
|
// fun buildCommandArgs(args: List<String>): List<String> {
|
||||||
|
// val cmd = listOf(binaryPath) + args
|
||||||
|
// Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args → cmd=$cmd")
|
||||||
|
// return cmd
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// 改为表达式体:
|
||||||
|
fun buildCommandArgs(args: List<String>): List<String> =
|
||||||
|
(listOf(binaryPath) + args).also { cmd ->
|
||||||
|
Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args → cmd=$cmd")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `./gradlew assembleDebug`
|
||||||
|
Expected: BUILD SUCCESSFUL
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -a -m "style: idiomatic Kotlin cleanup — lazy delegation, expression bodies"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: 基础单元测试框架
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/src/test/java/com/example/androidbackupgui/backup/AppErrorTest.kt`
|
||||||
|
- Modify: `app/build.gradle`
|
||||||
|
|
||||||
|
- [ ] **添加测试依赖**
|
||||||
|
|
||||||
|
```gradle
|
||||||
|
// app/build.gradle — dependencies 末尾追加
|
||||||
|
testImplementation 'junit:junit:4.13.2'
|
||||||
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||||
|
testImplementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **为 AppError 写单元测试**
|
||||||
|
|
||||||
|
Run: `./gradlew testDebugUnitTest --tests "*AppErrorTest*"`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -a -m "test: add unit test framework and AppError tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Self-Review
|
||||||
|
|
||||||
|
**1. Spec coverage:**
|
||||||
|
- Task 1-2 ✓ — 类型化错误处理覆盖 RemoteTransport 和 Restic 层
|
||||||
|
- Task 3-4 ✓ — 协程优化覆盖进度回调和结构化并发
|
||||||
|
- Task 5 ✓ — 安全加固覆盖 root shell 注入和密码日志
|
||||||
|
- Task 6 ✓ — Kotlin 惯用清理覆盖 BinaryResolver 和 CommandRunner
|
||||||
|
- Task 7 ✓ — 基础测试框架
|
||||||
|
|
||||||
|
**2. Placeholder check:** 无 TBD/TODO 占位。所有代码块包含完整实现。
|
||||||
|
|
||||||
|
**3. Type consistency:** `AppError`、`TransferEvent`、`AppResult` 在各 Task 之间一致。`RemoteTransport.upload/download` 签名在 Task 1 中修改后后续步骤保持一致引用。
|
||||||
1
kmboxnet
Submodule
1
kmboxnet
Submodule
Submodule kmboxnet added at 9b62283c62
Reference in New Issue
Block a user