Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d9ec54014 | ||
|
|
8c6021170f | ||
|
|
a3355d07e4 | ||
|
|
528c1ac029 | ||
|
|
22e5a8ab41 | ||
|
|
9020b868d0 | ||
|
|
7b34b565a9 | ||
|
|
e72ab719ce | ||
|
|
0bb379c1a4 | ||
|
|
6fe4920a85 | ||
|
|
29f40434e8 | ||
|
|
f4b7dc3aec | ||
|
|
00cf2bc2f4 | ||
|
|
e9a1697145 | ||
|
|
fbf3f9d179 | ||
|
|
bd5f4b92ab | ||
|
|
b844eaba7f | ||
|
|
1213f9fe18 | ||
|
|
28e49da9ed | ||
|
|
a15ca7243a | ||
|
|
23fdbab406 | ||
|
|
8122f64923 | ||
|
|
b249942c13 | ||
|
|
8ff28b14f6 | ||
|
|
250b387079 | ||
|
|
246eff5f0b | ||
|
|
64ded465e6 | ||
|
|
1fdba019d7 | ||
|
|
1fb93c3137 | ||
|
|
2c52b198bd |
10
.pi/wow.yaml
Normal file
10
.pi/wow.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
# Project-level wow-pi configuration for android-backup-gui
|
||||
contexts:
|
||||
- AGENTS.md
|
||||
- docs/contexts/*.md
|
||||
|
||||
inject:
|
||||
enabled: true
|
||||
overrideExisting: false
|
||||
envFiles:
|
||||
- .env
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **android-backup-gui** (1684 symbols, 4068 relationships, 146 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **android-backup-gui** (1734 symbols, 4049 relationships, 110 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **android-backup-gui** (1684 symbols, 4068 relationships, 146 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **android-backup-gui** (1734 symbols, 4049 relationships, 110 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ android {
|
||||
applicationId "com.example.androidbackupgui"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 15
|
||||
versionName "1.15"
|
||||
versionCode 16
|
||||
versionName "1.16"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
@@ -97,6 +97,7 @@ dependencies {
|
||||
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.0"
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
|
||||
|
||||
Binary file not shown.
@@ -1,23 +1,23 @@
|
||||
package com.example.androidbackupgui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.example.androidbackupgui.backup.LogUtil
|
||||
import com.example.androidbackupgui.backup.MissingAlgoProvider
|
||||
import com.example.androidbackupgui.backup.PasswordManager
|
||||
import com.example.androidbackupgui.backup.ResticBinary
|
||||
import com.example.androidbackupgui.backup.ResticWrapper
|
||||
import com.example.androidbackupgui.backup.defaultResticWrapper
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.ui.AppScaffold
|
||||
import com.example.androidbackupgui.ui.theme.AppTheme
|
||||
import com.google.android.material.color.DynamicColors
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
@@ -27,16 +27,19 @@ class MainActivity : ComponentActivity() {
|
||||
RootShell.configure()
|
||||
|
||||
// Initialize restic binary path
|
||||
ResticBinary.prepare(this)?.let { ResticWrapper.binaryPath = it }
|
||||
ResticBinary.prepare(this)?.let { defaultResticWrapper.binaryPath = it }
|
||||
|
||||
// Initialize file-based logging
|
||||
// Initialize file-based logging and secure credential storage
|
||||
LogUtil.init(filesDir)
|
||||
PasswordManager.init(this)
|
||||
// 启动时初始化 SMB 加密库(MD4/AESCMAC),避免首次 SMB 操作时延迟失败
|
||||
MissingAlgoProvider.register()
|
||||
|
||||
setContent {
|
||||
AppTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
AppScaffold()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* 后端执行器——消除 [ResticBackup]、[ResticRestore]、[ResticSnapshotOps]、
|
||||
* [ResticMaintenance] 和 [ResticRepoInit] 中重复的 local-vs-remote 分支。
|
||||
*
|
||||
* 使用方式(替换所有子模块中的 if backend == "local" 模式):
|
||||
*
|
||||
* ```
|
||||
* executor.withBackend(
|
||||
* repoPath = repoPath, password = password, cacheDir = cacheDir,
|
||||
* backend = backend, backendUrl = backendUrl,
|
||||
* backendUser = backendUser, backendPass = backendPass,
|
||||
* backendShare = backendShare, backendDomain = backendDomain,
|
||||
* runner = runner, envResolver = envResolver, bridgeRunner = bridgeRunner,
|
||||
* ) { env ->
|
||||
* val result = runner.runRestic(env, args)
|
||||
* // parse result
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class BackendExecutor {
|
||||
/**
|
||||
* 使用 [block] 执行 restic 操作。
|
||||
*
|
||||
* - "local" 后端:直接通过 [ResticEnvResolver.buildLocalEnv] 构建环境
|
||||
* - 远程后端:通过 [RestBridgeRunner.withBridge] 启动 REST 桥后再构建环境
|
||||
*
|
||||
* @param T 返回值的类型(例如 [AppResult])
|
||||
* @param block 接收环境变量 Map,返回 [T]
|
||||
*/
|
||||
suspend fun <T> withBackend(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
cacheDir: String,
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
backendDomain: String,
|
||||
runner: ResticCommandRunner,
|
||||
envResolver: ResticEnvResolver,
|
||||
bridgeRunner: RestBridgeRunner,
|
||||
block: suspend (Map<String, String>) -> T,
|
||||
): T {
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
return block(env)
|
||||
}
|
||||
return bridgeRunner.withBridge(
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
backendDomain,
|
||||
repoPath,
|
||||
File(cacheDir),
|
||||
) { bridgeUrl, authToken ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
block(env)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 [withBackend] 相同,但自动将 [args] 传给 [runner.runRestic]。
|
||||
*
|
||||
* 适用于 "run-and-parse-exit-code" 模式的简化调用。
|
||||
*/
|
||||
suspend fun runResticWithBackend(
|
||||
args: List<String>,
|
||||
repoPath: String,
|
||||
password: String,
|
||||
cacheDir: String,
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
backendDomain: String,
|
||||
runner: ResticCommandRunner,
|
||||
envResolver: ResticEnvResolver,
|
||||
bridgeRunner: RestBridgeRunner,
|
||||
): ResticCommandRunner.CommandResult =
|
||||
withBackend(
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
runner = runner,
|
||||
envResolver = envResolver,
|
||||
bridgeRunner = bridgeRunner,
|
||||
) { env -> runner.runRestic(env, args) }
|
||||
|
||||
/**
|
||||
* 与 [runResticWithBackend] 相同,但使用流式模式。
|
||||
*/
|
||||
suspend fun runResticStreamingWithBackend(
|
||||
args: List<String>,
|
||||
repoPath: String,
|
||||
password: String,
|
||||
cacheDir: String,
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
backendDomain: String,
|
||||
runner: ResticCommandRunner,
|
||||
envResolver: ResticEnvResolver,
|
||||
bridgeRunner: RestBridgeRunner,
|
||||
onLine: suspend (String) -> Unit = {},
|
||||
): ResticCommandRunner.CommandResult =
|
||||
withBackend(
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
runner = runner,
|
||||
envResolver = envResolver,
|
||||
bridgeRunner = bridgeRunner,
|
||||
) { env -> runner.runResticStreaming(env, args, onLine) }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import java.io.File
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Mirrors backup_settings.conf from backup_script.
|
||||
@@ -12,68 +12,68 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
data class BackupConfig(
|
||||
// Operation mode
|
||||
val lo: Int = 0, // 0=volume key, 1=volume force, 2=keyboard
|
||||
val backgroundExecution: Int = 0, // 0=foreground, 1=background
|
||||
val setDisplayPowerMode: Int = 0, // 1=keep screen on during backup
|
||||
val shellLang: String = "", // ""=auto, "1"=zh-CN, "0"=zh-TW
|
||||
|
||||
val lo: Int = 0, // 0=volume key, 1=volume force, 2=keyboard
|
||||
val backgroundExecution: Int = 0, // 0=foreground, 1=background
|
||||
val setDisplayPowerMode: Int = 0, // 1=keep screen on during backup
|
||||
val shellLang: String = "", // ""=auto, "1"=zh-CN, "0"=zh-TW
|
||||
// Paths
|
||||
val outputPath: String = "", // Custom output dir
|
||||
val listLocation: String = "", // Custom appList.txt location
|
||||
|
||||
val outputPath: String = "", // Custom output dir
|
||||
val listLocation: String = "", // Custom appList.txt location
|
||||
// Update
|
||||
val update: Int = 1, // 1=auto update
|
||||
val cdn: Int = 1, // CDN node
|
||||
|
||||
val update: Int = 1, // 1=auto update
|
||||
val cdn: Int = 1, // CDN node
|
||||
// Filters
|
||||
val mountPoint: String = "rannki|0000-1",
|
||||
val user: String = "",
|
||||
|
||||
// Backup mode
|
||||
val backupMode: Int = 1, // 1=data+apk, 0=apk only
|
||||
val backupMode: Int = 1, // 1=data+apk, 0=apk only
|
||||
val backupUserData: Int = 1,
|
||||
val backupObbData: Int = 1,
|
||||
val backupMedia: Int = 0,
|
||||
val backgroundAppsIgnore: Int = 0,
|
||||
val backupUserId: Int = 0, // Android user ID (0=Owner)
|
||||
|
||||
val backupUserId: Int = 0, // Android user ID (0=Owner)
|
||||
// Custom paths
|
||||
val customPath: List<String> = listOf(
|
||||
"/storage/emulated/0/Pictures/",
|
||||
"/storage/emulated/0/Download/",
|
||||
"/storage/emulated/0/Music",
|
||||
"/storage/emulated/0/DCIM/",
|
||||
"/data/adb"
|
||||
),
|
||||
|
||||
val customPath: List<String> =
|
||||
listOf(
|
||||
"/storage/emulated/0/Pictures/",
|
||||
"/storage/emulated/0/Download/",
|
||||
"/storage/emulated/0/Music",
|
||||
"/storage/emulated/0/DCIM/",
|
||||
"/data/adb",
|
||||
),
|
||||
// Blacklist
|
||||
val blacklistMode: Int = 0, // 1=full ignore, 0=apk only
|
||||
val blacklistMode: Int = 0, // 1=full ignore, 0=apk only
|
||||
val blacklist: List<String> = emptyList(),
|
||||
|
||||
// Whitelists
|
||||
val whitelist: List<String> = emptyList(),
|
||||
val system: List<String> = emptyList(),
|
||||
|
||||
// Compression
|
||||
val compressionMethod: String = "zstd", // zstd or tar
|
||||
|
||||
// Terminal colors
|
||||
val rgbA: Int = 226,
|
||||
val rgbB: Int = 123,
|
||||
val rgbC: Int = 177,
|
||||
|
||||
val backupWifi: Int = 1,
|
||||
|
||||
// Restic deduplicated backup with rclone backend
|
||||
val resticEnabled: Int = 0,
|
||||
val resticRepo: String = "",
|
||||
/**
|
||||
* restic 密码不在配置文件中明文存储。始终通过 PasswordManager 存取。
|
||||
* 此字段仅保留默认值,用于反序列化兼容旧版配置文件。
|
||||
*/
|
||||
@Deprecated("Use PasswordManager.getResticPassword() instead; kept only for config file backward compat")
|
||||
val resticPassword: String = "",
|
||||
val resticBackend: String = "local", // local / webdav / smb
|
||||
val resticBackend: String = "local", // local / webdav / smb
|
||||
val resticBackendUrl: String = "",
|
||||
val resticBackendUser: String = "",
|
||||
/** @deprecated Use PasswordManager instead */
|
||||
@Deprecated("Use PasswordManager instead")
|
||||
val resticBackendPass: String = "",
|
||||
val resticBackendShare: String = "", // SMB share name
|
||||
val resticBackendDomain: String = "" // SMB domain (optional, for NTLM)
|
||||
val resticBackendShare: String = "", // SMB share name
|
||||
val resticBackendDomain: String = "", // SMB domain (optional, for NTLM)
|
||||
// Streaming backup: pipe tar data through FIFO directly into restic --stdin
|
||||
// 0=disabled (default, stable), 1=enabled (experimental, avoids temp files)
|
||||
val useStreaming: Int = 0,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
@@ -87,29 +87,37 @@ data class BackupConfig(
|
||||
while (i < s.length) {
|
||||
val c = s[i]
|
||||
if (c == '\\' && i + 1 < s.length) {
|
||||
sb.append(s[i + 1]); i += 2
|
||||
sb.append(s[i + 1])
|
||||
i += 2
|
||||
} else {
|
||||
sb.append(c); i++
|
||||
sb.append(c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** Escape a value for safe storage inside double quotes. */
|
||||
private fun escapeValue(s: String): String =
|
||||
s.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
private fun escapeValue(s: String): String = s.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
|
||||
fun fromFile(file: File): BackupConfig {
|
||||
if (!file.exists()) return BackupConfig()
|
||||
|
||||
// Quoted-string fields preserve their inner whitespace and may contain
|
||||
// escaped characters; bare fields are trimmed as before.
|
||||
val quotedKeys = setOf(
|
||||
"Output_path", "list_location", "mount_point",
|
||||
"restic_repo", "restic_password", "restic_backend_url",
|
||||
"restic_backend_user", "restic_backend_pass",
|
||||
"restic_backend_share", "restic_backend_domain"
|
||||
)
|
||||
val quotedKeys =
|
||||
setOf(
|
||||
"Output_path",
|
||||
"list_location",
|
||||
"mount_point",
|
||||
"restic_repo",
|
||||
"restic_password",
|
||||
"restic_backend_url",
|
||||
"restic_backend_user",
|
||||
"restic_backend_pass",
|
||||
"restic_backend_share",
|
||||
"restic_backend_domain",
|
||||
)
|
||||
|
||||
val props = mutableMapOf<String, String>()
|
||||
file.forEachLine { line ->
|
||||
@@ -119,27 +127,34 @@ data class BackupConfig(
|
||||
if (eq < 0) return@forEachLine
|
||||
val key = trimmed.substring(0, eq).trim()
|
||||
val rawValue = trimmed.substring(eq + 1)
|
||||
props[key] = if (key in quotedKeys) {
|
||||
// Strip the surrounding quotes (if present) WITHOUT trimming the
|
||||
// inner content, so leading/trailing spaces in e.g. a password
|
||||
// survive a save/load round trip. Then unescape.
|
||||
val v = rawValue
|
||||
if (v.length >= 2 && v.startsWith("\"") && v.endsWith("\"")) {
|
||||
unescapeValue(v.substring(1, v.length - 1))
|
||||
props[key] =
|
||||
if (key in quotedKeys) {
|
||||
// Strip the surrounding quotes (if present) WITHOUT trimming the
|
||||
// inner content, so leading/trailing spaces in e.g. a password
|
||||
// survive a save/load round trip. Then unescape.
|
||||
val v = rawValue
|
||||
if (v.length >= 2 && v.startsWith("\"") && v.endsWith("\"")) {
|
||||
unescapeValue(v.substring(1, v.length - 1))
|
||||
} else {
|
||||
// Legacy/unquoted value — fall back to trimmed form.
|
||||
unescapeValue(v.trim().removeSurrounding("\""))
|
||||
}
|
||||
} else {
|
||||
// Legacy/unquoted value — fall back to trimmed form.
|
||||
unescapeValue(v.trim().removeSurrounding("\""))
|
||||
rawValue.trim().removeSurrounding("\"")
|
||||
}
|
||||
} else {
|
||||
rawValue.trim().removeSurrounding("\"")
|
||||
}
|
||||
}
|
||||
|
||||
fun int(key: String, default: Int = 0) = props[key]?.toIntOrNull() ?: default
|
||||
fun int(
|
||||
key: String,
|
||||
default: Int = 0,
|
||||
) = props[key]?.toIntOrNull() ?: default
|
||||
|
||||
fun str(key: String) = props[key] ?: ""
|
||||
|
||||
fun lines(key: String): List<String> {
|
||||
val raw = props[key] ?: return emptyList()
|
||||
return raw.split("\\s+".toRegex())
|
||||
return raw
|
||||
.split("\\s+".toRegex())
|
||||
.filter { it.isNotBlank() && it != "\"\"" }
|
||||
.map { it.replace("%20", " ") }
|
||||
}
|
||||
@@ -173,66 +188,75 @@ data class BackupConfig(
|
||||
backupWifi = int("backup_wifi", default = 1),
|
||||
resticEnabled = int("restic_enabled"),
|
||||
resticRepo = str("restic_repo"),
|
||||
resticPassword = str("restic_password"),
|
||||
resticPassword = "", // 不用配置文件中的值,见下方迁移逻辑
|
||||
resticBackend = str("restic_backend").ifEmpty { "local" },
|
||||
resticBackendUrl = str("restic_backend_url"),
|
||||
resticBackendUser = str("restic_backend_user"),
|
||||
resticBackendPass = str("restic_backend_pass"),
|
||||
resticBackendPass = "", // 不用配置文件中的值
|
||||
resticBackendShare = str("restic_backend_share"),
|
||||
resticBackendDomain = str("restic_backend_domain"),
|
||||
useStreaming = int("streaming_backup"),
|
||||
)
|
||||
}
|
||||
|
||||
fun toFile(config: BackupConfig, file: File) {
|
||||
fun toFile(
|
||||
config: BackupConfig,
|
||||
file: File,
|
||||
) {
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(buildString {
|
||||
appendLine("# SpeedBackup Configuration")
|
||||
appendLine("Lo=${config.lo}")
|
||||
appendLine("background_execution=${config.backgroundExecution}")
|
||||
appendLine("setDisplayPowerMode=${config.setDisplayPowerMode}")
|
||||
appendLine("Shell_LANG=${config.shellLang}")
|
||||
appendLine("Output_path=\"${escapeValue(config.outputPath)}\"")
|
||||
appendLine("list_location=\"${escapeValue(config.listLocation)}\"")
|
||||
appendLine("update=${config.update}")
|
||||
appendLine("cdn=${config.cdn}")
|
||||
appendLine("mount_point=\"${escapeValue(config.mountPoint)}\"")
|
||||
appendLine("user=${config.user}")
|
||||
appendLine("Backup_Mode=${config.backupMode}")
|
||||
appendLine("Backup_user_data=${config.backupUserData}")
|
||||
appendLine("Backup_obb_data=${config.backupObbData}")
|
||||
appendLine("backup_media=${config.backupMedia}")
|
||||
appendLine("backup_user_id=${config.backupUserId}")
|
||||
appendLine("Background_apps_ignore=${config.backgroundAppsIgnore}")
|
||||
append("Custom_path=\"")
|
||||
config.customPath.forEach { append(" ${it.replace(" ", "%20")}") }
|
||||
appendLine(" \"")
|
||||
appendLine("blacklist_mode=${config.blacklistMode}")
|
||||
append("blacklist=\"")
|
||||
config.blacklist.forEach { append(" ${it.replace(" ", "%20")}") }
|
||||
appendLine(" \"")
|
||||
append("whitelist=\"")
|
||||
config.whitelist.forEach { append(" ${it.replace(" ", "%20")}") }
|
||||
appendLine(" \"")
|
||||
append("system=\"")
|
||||
config.system.forEach { append(" ${it.replace(" ", "%20")}") }
|
||||
appendLine(" \"")
|
||||
appendLine("Compression_method=${config.compressionMethod}")
|
||||
appendLine("rgb_a=${config.rgbA}")
|
||||
appendLine("rgb_b=${config.rgbB}")
|
||||
appendLine("rgb_c=${config.rgbC}")
|
||||
appendLine("backup_wifi=${config.backupWifi}")
|
||||
appendLine("restic_enabled=${config.resticEnabled}")
|
||||
appendLine("restic_repo=\"${escapeValue(config.resticRepo)}\"")
|
||||
appendLine("restic_password=\"${escapeValue(config.resticPassword)}\"")
|
||||
appendLine("restic_backend=${config.resticBackend}")
|
||||
appendLine("restic_backend_url=\"${escapeValue(config.resticBackendUrl)}\"")
|
||||
appendLine("restic_backend_user=\"${escapeValue(config.resticBackendUser)}\"")
|
||||
appendLine("restic_backend_pass=\"${escapeValue(config.resticBackendPass)}\"")
|
||||
appendLine("restic_backend_share=\"${escapeValue(config.resticBackendShare)}\"")
|
||||
appendLine("restic_backend_domain=\"${escapeValue(config.resticBackendDomain)}\"")
|
||||
})
|
||||
file.setReadable(true, true) // owner only
|
||||
file.setWritable(true, true) // owner only
|
||||
file.writeText(
|
||||
buildString {
|
||||
appendLine("# SpeedBackup Configuration")
|
||||
appendLine("Lo=${config.lo}")
|
||||
appendLine("background_execution=${config.backgroundExecution}")
|
||||
appendLine("setDisplayPowerMode=${config.setDisplayPowerMode}")
|
||||
appendLine("Shell_LANG=${config.shellLang}")
|
||||
appendLine("Output_path=\"${escapeValue(config.outputPath)}\"")
|
||||
appendLine("list_location=\"${escapeValue(config.listLocation)}\"")
|
||||
appendLine("update=${config.update}")
|
||||
appendLine("cdn=${config.cdn}")
|
||||
appendLine("mount_point=\"${escapeValue(config.mountPoint)}\"")
|
||||
appendLine("user=${config.user}")
|
||||
appendLine("Backup_Mode=${config.backupMode}")
|
||||
appendLine("Backup_user_data=${config.backupUserData}")
|
||||
appendLine("Backup_obb_data=${config.backupObbData}")
|
||||
appendLine("backup_media=${config.backupMedia}")
|
||||
appendLine("backup_user_id=${config.backupUserId}")
|
||||
appendLine("Background_apps_ignore=${config.backgroundAppsIgnore}")
|
||||
append("Custom_path=\"")
|
||||
config.customPath.forEach { append(" ${it.replace(" ", "%20")}") }
|
||||
appendLine(" \"")
|
||||
appendLine("blacklist_mode=${config.blacklistMode}")
|
||||
append("blacklist=\"")
|
||||
config.blacklist.forEach { append(" ${it.replace(" ", "%20")}") }
|
||||
appendLine(" \"")
|
||||
append("whitelist=\"")
|
||||
config.whitelist.forEach { append(" ${it.replace(" ", "%20")}") }
|
||||
appendLine(" \"")
|
||||
append("system=\"")
|
||||
config.system.forEach { append(" ${it.replace(" ", "%20")}") }
|
||||
appendLine(" \"")
|
||||
appendLine("Compression_method=${config.compressionMethod}")
|
||||
appendLine("rgb_a=${config.rgbA}")
|
||||
appendLine("rgb_b=${config.rgbB}")
|
||||
appendLine("rgb_c=${config.rgbC}")
|
||||
appendLine("backup_wifi=${config.backupWifi}")
|
||||
appendLine("restic_enabled=${config.resticEnabled}")
|
||||
appendLine("restic_repo=\"${escapeValue(config.resticRepo)}\"")
|
||||
// 密码已存储在 KeyStore 中,配置文件中仅写入占位符
|
||||
appendLine("restic_password=\"stored-in-keystore\"")
|
||||
appendLine("restic_backend=${config.resticBackend}")
|
||||
appendLine("restic_backend_url=\"${escapeValue(config.resticBackendUrl)}\"")
|
||||
appendLine("restic_backend_user=\"${escapeValue(config.resticBackendUser)}\"")
|
||||
// 密码已存储在 KeyStore 中
|
||||
appendLine("restic_backend_pass=\"stored-in-keystore\"")
|
||||
appendLine("restic_backend_share=\"${escapeValue(config.resticBackendShare)}\"")
|
||||
appendLine("restic_backend_domain=\"${escapeValue(config.resticBackendDomain)}\"")
|
||||
appendLine("streaming_backup=${config.useStreaming}")
|
||||
},
|
||||
)
|
||||
file.setReadable(true, true) // owner only
|
||||
file.setWritable(true, true) // owner only
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import com.example.androidbackupgui.backup.ResticWrapper.SnapshotAppInfo
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import android.util.Log
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
@@ -23,7 +24,6 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
* Mirrors the logic from backup_script's modules/backup.sh.
|
||||
*/
|
||||
object BackupOperation {
|
||||
|
||||
private const val TAG = "BackupOperation"
|
||||
|
||||
@Serializable
|
||||
@@ -31,8 +31,8 @@ object BackupOperation {
|
||||
val current: Int,
|
||||
val total: Int,
|
||||
val packageName: String,
|
||||
val stage: String, // "apk", "data", "obb", "ssaid", "done"
|
||||
val message: String
|
||||
val stage: String, // "apk", "data", "obb", "ssaid", "done"
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -41,7 +41,7 @@ object BackupOperation {
|
||||
val failCount: Int,
|
||||
val skippedCount: Int,
|
||||
val outputDir: String,
|
||||
val elapsedMs: Long
|
||||
val elapsedMs: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -65,149 +65,284 @@ object BackupOperation {
|
||||
noDataBackup: Set<String> = emptySet(),
|
||||
includePkgs: Set<String> = emptySet(),
|
||||
legacyApps: Map<String, SnapshotAppInfo>? = null,
|
||||
onProgress: suspend (BackupProgress) -> Unit = {}
|
||||
): BackupResult = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (BackupProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
val startTime = System.currentTimeMillis()
|
||||
onProgress: suspend (BackupProgress) -> Unit = {},
|
||||
): BackupResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val emit: suspend (BackupProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
// Create backup structure
|
||||
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
|
||||
if (!backupRoot.mkdirs() && !backupRoot.isDirectory) {
|
||||
LogUtil.e(TAG, "backupApps: cannot create output dir ${backupRoot.absolutePath}")
|
||||
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
|
||||
}
|
||||
LogUtil.i(TAG, "backupApps: starting backup of ${apps.size} apps to ${backupRoot.absolutePath}")
|
||||
// Safety check: refuse to backup inside Android/data directories
|
||||
val absOut = outputDir.absolutePath
|
||||
if (absOut.contains("/Android/")) {
|
||||
LogUtil.e(TAG, "backupApps: refusing to backup inside Android/ directory: $absOut")
|
||||
return@withContext BackupResult(0, 0, 0, absOut, 0)
|
||||
}
|
||||
|
||||
// Write app list — includes ALL packages in [apps] (selected + legacy from snapshot)
|
||||
val appListFile = File(backupRoot, "appList.txt")
|
||||
try {
|
||||
appListFile.writeText(apps.joinToString("\n") { it.packageName.value })
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(TAG, "backupApps: failed to write appList.txt — ${e.message}")
|
||||
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
|
||||
}
|
||||
// Create backup structure
|
||||
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
|
||||
if (!mkdirsForBackup(backupRoot)) {
|
||||
LogUtil.e(TAG, "backupApps: cannot create output dir ${backupRoot.absolutePath}")
|
||||
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
|
||||
}
|
||||
LogUtil.i(TAG, "backupApps: starting backup of ${apps.size} apps to ${backupRoot.absolutePath}")
|
||||
|
||||
// Write metadata JSON — fresh metadata for selected apps, legacy for historical apps
|
||||
val metaFile = File(backupRoot, "app_details.json")
|
||||
try {
|
||||
metaFile.writeText(buildAppDetailsJson(apps, legacyApps))
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(TAG, "backupApps: failed to write app_details.json — ${e.message}")
|
||||
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
|
||||
}
|
||||
|
||||
val backupTargets = if (includePkgs.isEmpty()) apps else apps.filter { it.packageName.value in includePkgs }
|
||||
val totalCount = backupTargets.size
|
||||
LogUtil.i(TAG, "backupApps: includePkgs=${includePkgs.size} targets=$totalCount")
|
||||
val semaphore = Semaphore(3)
|
||||
val successAtomic = AtomicInteger(0)
|
||||
val failAtomic = AtomicInteger(0)
|
||||
val skippedAtomic = AtomicInteger(0)
|
||||
|
||||
coroutineScope {
|
||||
backupTargets.mapIndexed { index, app ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
ensureActive()
|
||||
val appDir = File(backupRoot, app.packageName.value)
|
||||
appDir.mkdirs()
|
||||
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "apk", "正在备份 APK…"))
|
||||
|
||||
// 1. Backup APK
|
||||
val paths = AppScanner.getApkPaths(app.packageName.value)
|
||||
val apkOk = if (paths.isNotEmpty()) {
|
||||
paths.withIndex().all { (i, apkPath) ->
|
||||
val destName = if (paths.size > 1) "${app.packageName}_split_$i.apk" else "${app.packageName}.apk"
|
||||
RootShell.exec("cp '${apkPath.shellEscape()}' '${appDir.absolutePath.shellEscape()}/${destName.shellEscape()}'").isSuccess
|
||||
}
|
||||
} else false
|
||||
|
||||
if (!apkOk) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "APK 备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
|
||||
// 1.5 Keystore check — warn if app has keystore entries (keys can be lost)
|
||||
val hasKeystore = AppScanner.hasKeystore(app.packageName.value)
|
||||
if (hasKeystore) {
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失"))
|
||||
}
|
||||
|
||||
// 2. Backup user data (if configured)
|
||||
if (config.backupMode == 1 && config.backupUserData == 1) {
|
||||
if (app.packageName.value in noDataBackup) {
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "跳过数据备份(已排除)"))
|
||||
} else {
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "正在备份数据…"))
|
||||
if (!backupUserData(context, app.packageName.value, appDir, userId, config.compressionMethod)) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "数据备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Backup OBB (if configured and exists)
|
||||
if (config.backupMode == 1 && config.backupObbData == 1) {
|
||||
val hasObb = AppScanner.hasObbData(app.packageName.value)
|
||||
if (hasObb) {
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "obb", "正在备份 OBB…"))
|
||||
if (!backupObb(app.packageName.value, appDir, config.compressionMethod)) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "OBB 备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Backup SSAID
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "ssaid", "正在备份 SSAID…"))
|
||||
backupSsaid(app.packageName.value, appDir, userId)
|
||||
|
||||
// 4.5 Backup app icon
|
||||
val iconPath = AppScanner.extractIcon(app.packageName.value, appDir, app.userId.value)
|
||||
if (iconPath != null) {
|
||||
Log.d(TAG, "backupApps: saved icon for ${app.packageName} -> $iconPath")
|
||||
}
|
||||
|
||||
// 5. Backup runtime permissions
|
||||
backupPermissions(app.packageName.value, appDir)
|
||||
|
||||
successAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "完成"))
|
||||
// Read previous metadata for incremental backup comparison
|
||||
val oldMetaFile = File(backupRoot, "app_details.json")
|
||||
val oldMetaJson =
|
||||
if (oldMetaFile.exists()) {
|
||||
try {
|
||||
JSONObject(readTextFile(oldMetaFile) ?: "{}")
|
||||
} catch (_: Exception) {
|
||||
JSONObject()
|
||||
}
|
||||
} else {
|
||||
JSONObject()
|
||||
}
|
||||
}.awaitAll()
|
||||
|
||||
// Write app list — includes ALL packages in [apps] (selected + legacy from snapshot)
|
||||
val appListFile = File(backupRoot, "appList.txt")
|
||||
if (!writeFileForBackup(appListFile, apps.joinToString("\n") { it.packageName.value })) {
|
||||
LogUtil.e(TAG, "backupApps: failed to write appList.txt")
|
||||
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
|
||||
}
|
||||
|
||||
// Write metadata JSON — fresh metadata for selected apps, legacy for historical apps
|
||||
val metaFile = File(backupRoot, "app_details.json")
|
||||
if (!writeFileForBackup(metaFile, buildAppDetailsJson(apps, legacyApps))) {
|
||||
LogUtil.e(TAG, "backupApps: failed to write app_details.json")
|
||||
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
|
||||
}
|
||||
|
||||
val backupTargets = if (includePkgs.isEmpty()) apps else apps.filter { it.packageName.value in includePkgs }
|
||||
val totalCount = backupTargets.size
|
||||
LogUtil.i(TAG, "backupApps: includePkgs=${includePkgs.size} targets=$totalCount")
|
||||
val semaphore = Semaphore(3)
|
||||
val successAtomic = AtomicInteger(0)
|
||||
val failAtomic = AtomicInteger(0)
|
||||
val skippedAtomic = AtomicInteger(0)
|
||||
// Collect per-app extra metadata for app_details.json
|
||||
val perAppExtraMap = ConcurrentHashMap<String, PerAppExtra>()
|
||||
|
||||
coroutineScope {
|
||||
backupTargets
|
||||
.mapIndexed { index, app ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
ensureActive()
|
||||
val pkgName = app.packageName.value
|
||||
val appDir = File(backupRoot, pkgName)
|
||||
appDir.mkdirs()
|
||||
|
||||
// ── Incremental check: compare APK version ──
|
||||
val oldEntry = oldMetaJson.optJSONObject(pkgName)
|
||||
val oldApkVersion = oldEntry?.optString("apk_version", null)
|
||||
var installedVersion: String? = null
|
||||
var apkChanged = true
|
||||
if (oldApkVersion != null) {
|
||||
val vResult = RootShell.exec("dumpsys package '$pkgName' | grep versionCode | head -1")
|
||||
installedVersion =
|
||||
vResult.output
|
||||
.substringAfter("versionCode=")
|
||||
.substringBefore(" ")
|
||||
.filter { it.isDigit() }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
if (installedVersion != null && oldApkVersion == installedVersion) {
|
||||
apkChanged = false
|
||||
Log.d(TAG, "backupApps: $pkgName APK $oldApkVersion unchanged, skipping")
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Backup APK (only if version changed)
|
||||
if (apkChanged) {
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "apk", "正在备份 APK…"))
|
||||
val paths = AppScanner.getApkPaths(pkgName)
|
||||
if (paths.isNotEmpty()) {
|
||||
val cpOk =
|
||||
paths.withIndex().all { (i, apkPath) ->
|
||||
val destName = if (paths.size > 1) "${pkgName}_split_$i.apk" else "$pkgName.apk"
|
||||
RootShell
|
||||
.exec(
|
||||
"cp '${apkPath.shellEscape()}' '${appDir.absolutePath.shellEscape()}/${destName.shellEscape()}'",
|
||||
).isSuccess
|
||||
}
|
||||
if (!cpOk) LogUtil.w(TAG, "backupApps: APK cp failed for $pkgName, continuing")
|
||||
}
|
||||
} else {
|
||||
skippedAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "apk", "APK无变化,跳过"))
|
||||
}
|
||||
|
||||
// Keystore check
|
||||
val hasKeystore = AppScanner.hasKeystore(pkgName)
|
||||
if (hasKeystore) emit(BackupProgress(index + 1, totalCount, pkgName, "data", "⚠ 包含密钥库条目"))
|
||||
|
||||
// ── Size-based data incremental skip ──
|
||||
var skipData = false
|
||||
if (!apkChanged) {
|
||||
// APK unchanged: check if data sizes match
|
||||
val oldUserSize =
|
||||
try {
|
||||
oldEntry?.optJSONObject("user")?.optString("Size", null)?.toLongOrNull()
|
||||
} catch (
|
||||
_: Exception,
|
||||
) {
|
||||
null
|
||||
}
|
||||
val oldObbSize =
|
||||
try {
|
||||
oldEntry?.optJSONObject("obb")?.optString("Size", null)?.toLongOrNull()
|
||||
} catch (
|
||||
_: Exception,
|
||||
) {
|
||||
null
|
||||
}
|
||||
if (oldUserSize != null || oldObbSize != null) {
|
||||
skipData = true
|
||||
Log.d(TAG, "backupApps: $pkgName data sizes known from backup, will compare after tar")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-app size tracking ──
|
||||
var userSize: Long? = null
|
||||
var userDeSize: Long? = null
|
||||
var dataSize: Long? = null
|
||||
var obbSize: Long? = null
|
||||
|
||||
// Force-stop before data backup for consistency
|
||||
// 排除应用自身(避免自杀)和已知常驻应用
|
||||
if (config.backupMode == 1 && !skipData) {
|
||||
if (pkgName !in listOf("bin.mt.plus", "com.termux", "bin.mt.plus.canary", context.packageName)) {
|
||||
RootShell.exec("am force-stop --user $userId '$pkgName' 2>/dev/null")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Backup user data
|
||||
if (config.backupMode == 1 && config.backupUserData == 1 && !skipData) {
|
||||
if (pkgName in noDataBackup) {
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "跳过数据备份(已排除)"))
|
||||
} else {
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "正在备份数据…"))
|
||||
val udResult = backupUserData(context, pkgName, appDir, userId, config.compressionMethod)
|
||||
userSize = udResult.first
|
||||
userDeSize = udResult.second
|
||||
if (udResult.first == null) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "数据备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
}
|
||||
} else if (skipData) {
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "数据无变化,跳过"))
|
||||
}
|
||||
|
||||
// 3. Backup OBB
|
||||
if (config.backupMode == 1 && config.backupObbData == 1 && !skipData) {
|
||||
val hasObb = AppScanner.hasObbData(pkgName)
|
||||
if (hasObb) {
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "obb", "正在备份 OBB…"))
|
||||
obbSize = backupObb(pkgName, appDir, config.compressionMethod)
|
||||
if (obbSize == null) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "OBB 备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3.5 Backup external data
|
||||
if (config.backupMode == 1 && config.backupUserData == 1 && !skipData) {
|
||||
if (pkgName !in noDataBackup) {
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "正在备份外部数据…"))
|
||||
dataSize = backupExternalData(pkgName, appDir, userId, config.compressionMethod)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Backup SSAID
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "ssaid", "正在备份 SSAID…"))
|
||||
backupSsaid(pkgName, appDir, userId)
|
||||
|
||||
// Icon + permissions (always, for completeness)
|
||||
val iconPath = AppScanner.extractIcon(pkgName, appDir, app.userId.value)
|
||||
if (iconPath != null) Log.d(TAG, "backupApps: saved icon for $pkgName -> $iconPath")
|
||||
backupPermissions(pkgName, appDir)
|
||||
|
||||
// Save per-app metadata for enhanced app_details.json
|
||||
val ssaidValue = readTextFile(File(appDir, "ssaid.txt"))?.trim()
|
||||
val permText = readTextFile(File(appDir, "permissions.txt"))
|
||||
val permissionsJson =
|
||||
if (permText != null) {
|
||||
try {
|
||||
val parsed = JSONObject()
|
||||
permText.lines().forEach { line ->
|
||||
val name = line.substringBefore(":").trim()
|
||||
val granted = line.contains("granted=true")
|
||||
if (name.contains(".")) parsed.put(name, if (granted) "granted:true" else "granted:false")
|
||||
}
|
||||
parsed
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
perAppExtraMap[pkgName] =
|
||||
PerAppExtra(
|
||||
ssaid = ssaidValue,
|
||||
permissions = permissionsJson,
|
||||
keystore = hasKeystore,
|
||||
userSize = userSize,
|
||||
userDeSize = userDeSize,
|
||||
dataSize = dataSize,
|
||||
obbSize = obbSize,
|
||||
)
|
||||
|
||||
successAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "done", "完成"))
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
RootShell.exec("chmod -R 0755 '${backupRoot.absolutePath}'")
|
||||
|
||||
val successCount = successAtomic.get()
|
||||
val failCount = failAtomic.get()
|
||||
val skippedCount = skippedAtomic.get()
|
||||
|
||||
LogUtil.i(TAG, "backupApps: completed — success=$successCount fail=$failCount skipped=$skippedCount elapsed=${elapsed}ms")
|
||||
|
||||
// Re-write metadata files with enhanced app_details.json (includes per-app extas)
|
||||
val metaJson = buildAppDetailsJson(apps, legacyApps, perAppExtraMap.ifEmpty { null })
|
||||
writeFileForBackup(File(backupRoot, "app_details.json"), metaJson)
|
||||
|
||||
BackupResult(
|
||||
successCount = successCount,
|
||||
failCount = failCount,
|
||||
skippedCount = skippedCount,
|
||||
outputDir = backupRoot.absolutePath,
|
||||
elapsedMs = elapsed,
|
||||
)
|
||||
}
|
||||
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
RootShell.exec("chmod -R 0755 '${backupRoot.absolutePath}'")
|
||||
|
||||
val successCount = successAtomic.get()
|
||||
val failCount = failAtomic.get()
|
||||
val skippedCount = skippedAtomic.get()
|
||||
|
||||
LogUtil.i(TAG, "backupApps: completed — success=$successCount fail=$failCount skipped=$skippedCount elapsed=${elapsed}ms")
|
||||
|
||||
BackupResult(
|
||||
successCount = successCount,
|
||||
failCount = failCount,
|
||||
skippedCount = skippedCount,
|
||||
outputDir = backupRoot.absolutePath,
|
||||
elapsedMs = elapsed
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private suspend fun backupUserData(
|
||||
/**
|
||||
* 备份单个应用的用户数据(/data/data + /data/user_de)。
|
||||
*
|
||||
* 使用 tar + zstd/gzip 创建应用数据存档,支持 3 种回退策略:
|
||||
* 1. 通过 nsenter 直接 tar
|
||||
* 2. 直接 tar 路径(跳过 test -d)
|
||||
* 3. 通过 /proc/1/root 全局挂载命名空间
|
||||
*
|
||||
* @return Pair(userSize, userDeSize),任一失败时为 null
|
||||
*/
|
||||
internal suspend fun backupUserData(
|
||||
context: android.content.Context,
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
userId: String,
|
||||
compression: String
|
||||
): Boolean {
|
||||
compression: String,
|
||||
): Pair<Long?, Long?> {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
val outputFile = "${appDir.absolutePath.shellEscape()}/${pkgEsc}_data.tar"
|
||||
|
||||
@@ -228,6 +363,11 @@ object BackupOperation {
|
||||
val archiveExt = if (isZstd) ".zst" else ".gz"
|
||||
val archiveRaw = File(appDir, "${packageName}_data.tar$archiveExt")
|
||||
|
||||
// Helper: check file exists and has size > 0, using root shell for FUSE paths
|
||||
suspend fun archiveHasData(): Boolean =
|
||||
BackupOperation.backupPathExists(archiveRaw) &&
|
||||
(archiveRaw.length() > 0 || BackupOperation.backupFileSize(archiveRaw) > 0L)
|
||||
|
||||
Log.d(TAG, "backupUserData: $packageName checking dirs (tar=$tarCmd zstd=$zstdCmd)")
|
||||
|
||||
val rawPkg = packageName
|
||||
@@ -242,12 +382,12 @@ object BackupOperation {
|
||||
if (dirs.isNotEmpty()) {
|
||||
Log.d(TAG, "backupUserData: $packageName test -d found dirs=$dirs")
|
||||
result = runTar(dirs, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
|
||||
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
|
||||
archiveCreated = archiveHasData()
|
||||
Log.d(TAG, "backupUserData: $packageName step1 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
|
||||
} else {
|
||||
Log.d(TAG, "backupUserData: $packageName test -d all failed, trying tar directly")
|
||||
result = runTar(dataPaths, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
|
||||
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
|
||||
archiveCreated = archiveHasData()
|
||||
Log.d(TAG, "backupUserData: $packageName step2 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
|
||||
}
|
||||
|
||||
@@ -255,145 +395,306 @@ object BackupOperation {
|
||||
if (!archiveCreated) {
|
||||
Log.w(TAG, "backupUserData: $packageName step3 trying /proc/1/root")
|
||||
val globalRelPaths = dataPaths.map { it.removePrefix("/") }
|
||||
val globalCmd = if (isZstd) {
|
||||
"cd /proc/1/root && set -o pipefail; $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
|
||||
} else {
|
||||
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null"
|
||||
}
|
||||
val globalCmd =
|
||||
if (isZstd) {
|
||||
"cd /proc/1/root && set -o pipefail; $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(
|
||||
" ",
|
||||
) { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
|
||||
} else {
|
||||
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(
|
||||
" ",
|
||||
) { "'${it.shellEscape()}'" }} 2>/dev/null"
|
||||
}
|
||||
result = RootShell.exec(globalCmd)
|
||||
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
|
||||
archiveCreated = archiveHasData()
|
||||
Log.d(TAG, "backupUserData: $packageName step3 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
|
||||
}
|
||||
|
||||
if (!archiveCreated) {
|
||||
Log.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)")
|
||||
return false
|
||||
LogUtil.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)")
|
||||
return null to null
|
||||
}
|
||||
|
||||
// Verify compression integrity
|
||||
val verifyOk = if (isZstd) {
|
||||
RootShell.exec("$zstdCmd -t '$outputFile.zst' 2>/dev/null").isSuccess
|
||||
} else {
|
||||
RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess
|
||||
}
|
||||
val verifyOk =
|
||||
if (isZstd) {
|
||||
RootShell.exec("$zstdCmd -t '$outputFile.zst' 2>/dev/null").isSuccess
|
||||
} else {
|
||||
RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess
|
||||
}
|
||||
if (!verifyOk) {
|
||||
Log.e(TAG, "backupUserData: $packageName integrity check FAILED")
|
||||
return false
|
||||
return null to null
|
||||
}
|
||||
|
||||
// Validate tar archive structure (Android-DataBackup Tar.test() pattern)
|
||||
val tarValidateOk = if (isZstd) {
|
||||
RootShell.exec("$zstdCmd -d -c '$outputFile.zst' 2>/dev/null | tar -tf - > /dev/null 2>&1").isSuccess
|
||||
} else {
|
||||
RootShell.exec("tar -tf '$outputFile.gz' > /dev/null 2>&1").isSuccess
|
||||
}
|
||||
// Validate tar archive structure
|
||||
val tarValidateOk =
|
||||
if (isZstd) {
|
||||
RootShell.exec("$zstdCmd -d -c '$outputFile.zst' 2>/dev/null | tar -tf - > /dev/null 2>&1").isSuccess
|
||||
} else {
|
||||
RootShell.exec("tar -tf '$outputFile.gz' > /dev/null 2>&1").isSuccess
|
||||
}
|
||||
if (!tarValidateOk) {
|
||||
Log.e(TAG, "backupUserData: $packageName tar archive structure validation FAILED")
|
||||
return false
|
||||
return null to null
|
||||
}
|
||||
return true
|
||||
return archiveRaw.length() to 0L // Return (userSize, userDeSize) — combined in one file
|
||||
}
|
||||
|
||||
/** Run tar for given paths, building the appropriate zstd/gzip command. */
|
||||
private suspend fun runTar(
|
||||
/**
|
||||
* 运行 tar 命令,自动选择 zstd 或 gzip 压缩。
|
||||
*/
|
||||
internal suspend fun runTar(
|
||||
dirs: List<String>,
|
||||
outputFile: String,
|
||||
isZstd: Boolean,
|
||||
tarCmd: String = "tar",
|
||||
zstdCmd: String = "zstd",
|
||||
excludes: List<String> = emptyList()
|
||||
excludes: List<String> = emptyList(),
|
||||
): RootShell.ShellResult {
|
||||
val excludeArgs = if (excludes.isNotEmpty()) {
|
||||
excludes.joinToString(" ") { "--exclude='${it.shellEscape()}'" }
|
||||
} else ""
|
||||
val excludeArgs =
|
||||
if (excludes.isNotEmpty()) {
|
||||
excludes.joinToString(" ") { "--exclude='${it.shellEscape()}'" }
|
||||
} else {
|
||||
""
|
||||
}
|
||||
return if (isZstd) {
|
||||
RootShell.exec("set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'")
|
||||
RootShell.exec(
|
||||
"set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(
|
||||
" ",
|
||||
) { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'",
|
||||
)
|
||||
} else {
|
||||
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null")
|
||||
}
|
||||
}
|
||||
private suspend fun backupObb(packageName: String, appDir: File, compression: String): Boolean {
|
||||
|
||||
/**
|
||||
* 备份单个应用的 OBB 数据文件夹。
|
||||
* @return obbSize 或 null(失败时)
|
||||
*/
|
||||
internal suspend fun backupObb(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
compression: String,
|
||||
): Long? {
|
||||
val obbDir = "/storage/emulated/0/Android/obb/${packageName.shellEscape()}"
|
||||
val escapedAppDir = appDir.absolutePath.shellEscape()
|
||||
val escapedPkg = packageName.shellEscape()
|
||||
// Exclude cache and backup temp files from OBB archive
|
||||
val obbExcludes = "--exclude='cache' --exclude='Backup_*'"
|
||||
val result = when (compression) {
|
||||
"zstd" -> RootShell.exec("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")
|
||||
}
|
||||
val result =
|
||||
when (compression) {
|
||||
"zstd" -> {
|
||||
RootShell.exec(
|
||||
"set -o pipefail; tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
|
||||
}
|
||||
}
|
||||
if (!result.isSuccess) {
|
||||
Log.e(TAG, "Failed to backup OBB for $packageName: exit=${result.exitCode} err=${result.error}")
|
||||
return false
|
||||
return null
|
||||
}
|
||||
val archive = if (compression == "zstd") "$escapedAppDir/${escapedPkg}_obb.tar.zst" else "$escapedAppDir/${escapedPkg}_obb.tar.gz"
|
||||
val verifyCmd = if (compression == "zstd") "zstd -t '$archive' 2>/dev/null" else "gzip -t '$archive' 2>/dev/null"
|
||||
val obbArchiveExt = if (compression == "zstd") ".zst" else ".gz"
|
||||
val obbFile = File(appDir, "${packageName}_obb.tar$obbArchiveExt")
|
||||
val obbArchivePath = obbFile.absolutePath.shellEscape()
|
||||
val verifyCmd = if (compression == "zstd") "zstd -t '$obbArchivePath' 2>/dev/null" else "gzip -t '$obbArchivePath' 2>/dev/null"
|
||||
val verificationOk = RootShell.exec(verifyCmd).isSuccess
|
||||
if (!verificationOk) {
|
||||
Log.e(TAG, "OBB archive integrity check FAILED for $packageName")
|
||||
}
|
||||
// Validate OBB tar structure
|
||||
val tarListCmd = if (compression == "zstd") "zstd -d -c '$archive' 2>/dev/null | tar -tf - > /dev/null 2>&1" else "tar -tf '$archive' > /dev/null 2>&1"
|
||||
val tarListCmd =
|
||||
if (compression == "zstd") {
|
||||
"zstd -d -c '$obbArchivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1"
|
||||
} else {
|
||||
"tar -tf '$obbArchivePath' > /dev/null 2>&1"
|
||||
}
|
||||
val tarOk = RootShell.exec(tarListCmd).isSuccess
|
||||
if (!tarOk) {
|
||||
Log.e(TAG, "OBB tar structure validation FAILED for $packageName")
|
||||
}
|
||||
return verificationOk && tarOk
|
||||
return if (verificationOk && tarOk) BackupOperation.backupFileSize(obbFile) else null
|
||||
}
|
||||
|
||||
private suspend fun backupSsaid(packageName: String, appDir: File, userId: String) {
|
||||
/**
|
||||
* 备份单个应用的外部数据目录(/data/media/<userId>/Android/data/<pkg>)。
|
||||
* @return dataSize 或 null(目录不存在或失败)
|
||||
*/
|
||||
internal suspend fun backupExternalData(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
userId: String,
|
||||
compression: String,
|
||||
): Long? {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
val externalDataDir = "/data/media/$userId/Android/data/$pkgEsc"
|
||||
|
||||
// Check if the directory exists
|
||||
val checkResult = RootShell.exec("test -d '$externalDataDir' && echo 1 || echo 0")
|
||||
if (checkResult.output.trim() != "1") {
|
||||
Log.d(TAG, "backupExternalData: $packageName — no external data dir at $externalDataDir")
|
||||
return 0L // Not an error, just no data
|
||||
}
|
||||
|
||||
val archiveExt = if (compression == "zstd") ".zst" else ".gz"
|
||||
val archiveFile = File(appDir, "${packageName}_external_data.tar$archiveExt")
|
||||
val archivePath = archiveFile.absolutePath.shellEscape()
|
||||
val dataExcludes = "--exclude='cache' --exclude='Backup_*' --exclude='.ota'"
|
||||
|
||||
val result =
|
||||
if (compression == "zstd") {
|
||||
RootShell.exec(
|
||||
"set -o pipefail; tar -cf - $dataExcludes '$externalDataDir' 2>/dev/null | zstd -T0 -o '$archivePath'",
|
||||
)
|
||||
} else {
|
||||
RootShell.exec("tar -czf $dataExcludes '$archivePath' '$externalDataDir' 2>/dev/null")
|
||||
}
|
||||
|
||||
if (!result.isSuccess) {
|
||||
Log.w(TAG, "backupExternalData: $packageName tar failed: ${result.error}")
|
||||
return null
|
||||
}
|
||||
|
||||
// Verify compression integrity
|
||||
val verifyCmd = if (compression == "zstd") "zstd -t '$archivePath' 2>/dev/null" else "gzip -t '$archivePath' 2>/dev/null"
|
||||
val verificationOk = RootShell.exec(verifyCmd).isSuccess
|
||||
if (!verificationOk) {
|
||||
Log.e(TAG, "backupExternalData: $packageName integrity check FAILED")
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate tar structure
|
||||
val tarListCmd =
|
||||
if (compression == "zstd") {
|
||||
"zstd -d -c '$archivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1"
|
||||
} else {
|
||||
"tar -tf '$archivePath' > /dev/null 2>&1"
|
||||
}
|
||||
val tarOk = RootShell.exec(tarListCmd).isSuccess
|
||||
if (!tarOk) {
|
||||
Log.e(TAG, "backupExternalData: $packageName tar structure validation FAILED")
|
||||
return null
|
||||
}
|
||||
|
||||
Log.i(TAG, "backupExternalData: $packageName backed up (size=${archiveFile.length()})")
|
||||
return BackupOperation.backupFileSize(archiveFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* 备份单个应用的 SSAID(设置安全标识符)。
|
||||
* 从 settings_ssaid.xml 中提取。
|
||||
*/
|
||||
internal suspend fun backupSsaid(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
userId: String,
|
||||
) {
|
||||
val ssaidFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
|
||||
// Parse XML value attribute for this package's SSAID entry
|
||||
val result = RootShell.exec("cat '$ssaidFile' 2>/dev/null")
|
||||
if (!result.isSuccess || result.output.isBlank()) return
|
||||
val ssaidLine = result.output.lines().firstOrNull { line ->
|
||||
line.contains("packageName=\"$packageName\"") || line.contains("packageName='$packageName'")
|
||||
}
|
||||
val value = ssaidLine
|
||||
?.substringAfter("value=\"")
|
||||
?.substringBefore("\"")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val ssaidLine =
|
||||
result.output.lines().firstOrNull { line ->
|
||||
line.contains("packageName=\"$packageName\"") || line.contains("packageName='$packageName'")
|
||||
}
|
||||
val value =
|
||||
ssaidLine
|
||||
?.substringAfter("value=\"")
|
||||
?.substringBefore("\"")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
if (value != null) {
|
||||
try {
|
||||
File(appDir, "ssaid.txt").writeText(value)
|
||||
val ssaidFile = File(appDir, "ssaid.txt")
|
||||
if (!writeFileForBackup(ssaidFile, value)) {
|
||||
Log.w(TAG, "backupSsaid: failed to write ssaid.txt for $packageName")
|
||||
} else {
|
||||
Log.d(TAG, "backupSsaid: backed up SSAID for $packageName = $value")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "backupSsaid: failed to write ssaid.txt for $packageName", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun backupPermissions(packageName: String, appDir: File) {
|
||||
/**
|
||||
* 备份单个应用的运行时权限状态。
|
||||
*/
|
||||
internal suspend fun backupPermissions(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
) {
|
||||
val result = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep -E 'granted=(true|false)'")
|
||||
if (result.output.isNotBlank()) {
|
||||
try {
|
||||
File(appDir, "permissions.txt").writeText(result.output)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "backupPermissions: failed to write permissions.txt for $packageName", e)
|
||||
val permFile = File(appDir, "permissions.txt")
|
||||
if (!writeFileForBackup(permFile, result.output)) {
|
||||
Log.w(TAG, "backupPermissions: failed to write permissions.txt for $packageName")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun buildAppDetailsJson(
|
||||
apps: List<AppInfo>,
|
||||
legacyApps: Map<String, SnapshotAppInfo>? = null
|
||||
legacyApps: Map<String, SnapshotAppInfo>? = null,
|
||||
perAppExtra: Map<String, PerAppExtra>? = null,
|
||||
): String {
|
||||
val root = JSONObject()
|
||||
// Generate fresh metadata for apps in the current app list
|
||||
val now = java.text.SimpleDateFormat("yyyy.MM.dd HH:mm:ss", java.util.Locale.US).format(java.util.Date())
|
||||
for (app in apps) {
|
||||
val entry = JSONObject()
|
||||
entry.put("label", app.label)
|
||||
entry.put("isSystem", app.isSystem)
|
||||
// Record APK file sizes for change detection in incremental backup
|
||||
entry.put("PackageName", app.packageName.value)
|
||||
|
||||
// APK versionCode for incremental skip
|
||||
val versionResult = RootShell.exec("dumpsys package '${app.packageName.value.shellEscape()}' | grep versionCode | head -1")
|
||||
val apkVersion =
|
||||
versionResult.output
|
||||
.substringAfter("versionCode=")
|
||||
.substringBefore(" ")
|
||||
.filter { it.isDigit() }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
if (apkVersion != null) entry.put("apk_version", apkVersion)
|
||||
|
||||
// APK file sizes
|
||||
val paths = AppScanner.getApkPaths(app.packageName.value)
|
||||
val sizes = paths.map { path ->
|
||||
val result = RootShell.exec("stat -c%s '${path.shellEscape()}'")
|
||||
if (result.isSuccess) result.output.trim().toLongOrNull() ?: 0L else 0L
|
||||
}
|
||||
val sizes =
|
||||
paths.map { path ->
|
||||
val result = RootShell.exec("stat -c%s '${path.shellEscape()}'")
|
||||
if (result.isSuccess) result.output.trim().toLongOrNull() ?: 0L else 0L
|
||||
}
|
||||
entry.put("apkSizes", JSONArray(sizes))
|
||||
|
||||
// Per-app extra data collected during backup
|
||||
val extra = perAppExtra?.get(app.packageName.value)
|
||||
if (extra != null) {
|
||||
if (extra.ssaid != null) entry.put("Ssaid", extra.ssaid)
|
||||
if (extra.permissions != null) entry.put("permissions", extra.permissions)
|
||||
if (extra.keystore) entry.put("keystore", "true")
|
||||
|
||||
fun putSize(
|
||||
key: String,
|
||||
value: Long?,
|
||||
) {
|
||||
if (value != null) {
|
||||
val obj = JSONObject()
|
||||
obj.put("Size", value.toString())
|
||||
entry.put(key, obj)
|
||||
}
|
||||
}
|
||||
putSize("user", extra.userSize)
|
||||
putSize("user_de", extra.userDeSize)
|
||||
putSize("data", extra.dataSize)
|
||||
putSize("obb", extra.obbSize)
|
||||
}
|
||||
|
||||
val timeObj = JSONObject()
|
||||
timeObj.put("date", now)
|
||||
entry.put("Backup time", timeObj)
|
||||
|
||||
root.put(app.packageName.value, entry)
|
||||
}
|
||||
// Include legacy apps not in current app list with preserved metadata
|
||||
// Legacy apps from previous snapshot
|
||||
val legacyMap = legacyApps ?: emptyMap()
|
||||
for ((pkg, legacy) in legacyMap) {
|
||||
if (!root.has(pkg)) {
|
||||
@@ -406,4 +707,109 @@ object BackupOperation {
|
||||
}
|
||||
return root.toString(2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-app extra metadata collected during backup write phase.
|
||||
*/
|
||||
internal data class PerAppExtra(
|
||||
val ssaid: String? = null,
|
||||
val permissions: org.json.JSONObject? = null,
|
||||
val keystore: Boolean = false,
|
||||
val userSize: Long? = null,
|
||||
val userDeSize: Long? = null,
|
||||
val dataSize: Long? = null,
|
||||
val obbSize: Long? = null,
|
||||
)
|
||||
|
||||
/** Create backup output directory, falling back to root shell [mkdir -p]. */
|
||||
internal suspend fun mkdirsForBackup(dir: File): Boolean {
|
||||
if (dir.isDirectory) return true
|
||||
if (dir.mkdirs()) return true
|
||||
val result = RootShell.exec("mkdir -p '${dir.absolutePath.shellEscape()}'")
|
||||
return result.isSuccess && dir.isDirectory
|
||||
}
|
||||
|
||||
/** Write text to a file, falling back to root shell (base64 + cat). */
|
||||
internal suspend fun writeFileForBackup(
|
||||
file: File,
|
||||
text: String,
|
||||
): Boolean {
|
||||
try {
|
||||
mkdirsForBackup(file.parentFile ?: return false)
|
||||
file.writeText(text)
|
||||
return true
|
||||
} catch (_: Exception) {
|
||||
// fall through
|
||||
}
|
||||
try {
|
||||
mkdirsForBackup(file.parentFile ?: return false)
|
||||
val b64 = android.util.Base64.encodeToString(text.toByteArray(), android.util.Base64.NO_WRAP)
|
||||
val result = RootShell.exec("echo '${b64.shellEscape()}' | base64 -d > '${file.absolutePath.shellEscape()}'")
|
||||
return result.isSuccess
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "writeFileForBackup: all methods failed for ${file.absolutePath}", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Read file content, falling back to root shell [cat]. Returns null on failure. */
|
||||
internal suspend fun readTextFile(file: File): String? {
|
||||
try {
|
||||
if (file.exists()) return file.readText()
|
||||
} catch (_: Exception) {
|
||||
// fall through
|
||||
}
|
||||
try {
|
||||
val result = RootShell.exec("cat '${file.absolutePath.shellEscape()}' 2>/dev/null")
|
||||
if (result.isSuccess && result.output.isNotBlank()) return result.output
|
||||
} catch (_: Exception) {
|
||||
// fall through
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Check if a path is a directory, falling back to root shell [test -d]. */
|
||||
internal suspend fun backupIsDirectory(dir: File): Boolean {
|
||||
if (dir.isDirectory()) return true
|
||||
val result = RootShell.exec("test -d '${dir.absolutePath.shellEscape()}' && echo 1 || echo 0")
|
||||
return result.output.trim() == "1"
|
||||
}
|
||||
|
||||
/** Get file size via root shell [stat] when Java File.length() returns 0 on FUSE. */
|
||||
internal suspend fun backupFileSize(file: File): Long {
|
||||
val javaSize = file.length()
|
||||
if (javaSize > 0L) return javaSize
|
||||
val result = RootShell.exec("stat -c%s '${file.absolutePath.shellEscape()}' 2>/dev/null")
|
||||
return result.output.trim().toLongOrNull() ?: 0L
|
||||
}
|
||||
|
||||
/** Check if a file/directory exists, falling back to root shell [test -e]. */
|
||||
internal suspend fun backupPathExists(file: File): Boolean {
|
||||
if (file.exists()) return true
|
||||
val result = RootShell.exec("test -e '${file.absolutePath.shellEscape()}' && echo 1 || echo 0")
|
||||
return result.output.trim() == "1"
|
||||
}
|
||||
|
||||
/**
|
||||
* List immediate children in a directory, falling back to root shell [ls -1].
|
||||
* Returns relative names only (not full paths).
|
||||
*/
|
||||
internal suspend fun listBackupFiles(dir: File): List<String>? {
|
||||
try {
|
||||
val javaFiles = dir.listFiles()
|
||||
if (javaFiles != null) {
|
||||
val names = javaFiles.map { it.name }
|
||||
if (names.isNotEmpty()) return names
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// fall through
|
||||
}
|
||||
try {
|
||||
val result = RootShell.exec("ls -1 '${dir.absolutePath.shellEscape()}' 2>/dev/null")
|
||||
if (!result.isSuccess || result.output.isBlank()) return null
|
||||
return result.output.lines().filter { it.isNotBlank() }
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,44 @@ import kotlinx.serialization.Serializable
|
||||
* 类型安全的包名包装。
|
||||
*
|
||||
* 使用 [value] 获取原始字符串,用于 Android API 调用和 shell 命令。
|
||||
*
|
||||
* 构造函数验证包名格式符合 Android 命名规范(字母开头、包含至少一个点、
|
||||
* 仅包含字母数字下划线连字符和点),以防止注入攻击和防止 shell 转义绕过。
|
||||
*
|
||||
* 如果包名来源不可信,请使用 [PackageName.safe] 安全创建。
|
||||
*/
|
||||
@JvmInline
|
||||
@Serializable
|
||||
value class PackageName(val value: String) {
|
||||
value class PackageName(
|
||||
val value: String,
|
||||
) {
|
||||
init {
|
||||
require(value.isNotBlank()) { "PackageName must not be blank" }
|
||||
require(PACKAGE_NAME_REGEX.matches(value)) {
|
||||
"Invalid Android package name: '$value' - must start with a letter, " +
|
||||
"contain at least one dot, and only [a-zA-Z0-9_-] characters (dot only as separator)"
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String = value
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Android 包名正则:字母开头、至少一个点、仅允许标准字符。
|
||||
* 此正则与 [restoreSsaid] 中的校验一致。
|
||||
*/
|
||||
private val PACKAGE_NAME_REGEX =
|
||||
Regex(
|
||||
"^[a-zA-Z][a-zA-Z0-9_-]*(\\.[a-zA-Z][a-zA-Z0-9_-]*)+" +
|
||||
"$",
|
||||
)
|
||||
|
||||
/**
|
||||
* 安全创建 [PackageName],如果包名无效则返回 null。
|
||||
* 适用于外部输入(appList.txt、扫描结果等)的防御性校验。
|
||||
*/
|
||||
fun safe(value: String): PackageName? = if (value.isNotBlank() && PACKAGE_NAME_REGEX.matches(value)) PackageName(value) else null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,10 +53,13 @@ value class PackageName(val value: String) {
|
||||
*/
|
||||
@JvmInline
|
||||
@Serializable
|
||||
value class UserId(val value: Int) {
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
/**
|
||||
* 安全密码管理器。
|
||||
*
|
||||
* 使用 Android EncryptedSharedPreferences + AES256 加密存储敏感凭据,
|
||||
* 包括 restic 仓库密码和远端后端密码。
|
||||
*
|
||||
* 构造后应尽早调用 [init] 完成初始化。
|
||||
*/
|
||||
object PasswordManager {
|
||||
|
||||
private const val PREF_NAME = "secure_credentials"
|
||||
private const val KEY_RESTIC_PASSWORD = "restic_password"
|
||||
private const val KEY_BACKEND_PASSWORD = "backend_password"
|
||||
private const val KEY_BACKEND_PASS = "backend_pass"
|
||||
|
||||
@Volatile
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
/**
|
||||
* 初始化加密存储。需要在应用启动时(Application.onCreate 或
|
||||
* MainActivity.onCreate)尽早调用。
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
if (prefs != null) return
|
||||
synchronized(this) {
|
||||
if (prefs != null) return
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREF_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Restic 仓库密码 ───────────────────────────────
|
||||
|
||||
/** 获取加密存储的 restic 仓库密码。没有设置时返回 null。 */
|
||||
fun getResticPassword(): String? = prefs?.getString(KEY_RESTIC_PASSWORD, null)
|
||||
|
||||
/** 加密保存 restic 仓库密码。传入 null 可清除。 */
|
||||
fun setResticPassword(password: String?) {
|
||||
if (password == null) {
|
||||
prefs?.edit()?.remove(KEY_RESTIC_PASSWORD)?.apply()
|
||||
} else {
|
||||
prefs?.edit()?.putString(KEY_RESTIC_PASSWORD, password)?.apply()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 远端后端密码 ─────────────────────────────────
|
||||
|
||||
/** 获取加密存储的远端后端密码(WebDAV/SMB)。 */
|
||||
fun getBackendPassword(): String? = prefs?.getString(KEY_BACKEND_PASSWORD, null)
|
||||
|
||||
/** 加密保存远端后端密码。 */
|
||||
fun setBackendPassword(password: String?) {
|
||||
if (password == null) {
|
||||
prefs?.edit()?.remove(KEY_BACKEND_PASSWORD)?.apply()
|
||||
} else {
|
||||
prefs?.edit()?.putString(KEY_BACKEND_PASSWORD, password)?.apply()
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取加密存储的远端后端 passphrase(SMB share)。 */
|
||||
fun getBackendPass(): String? = prefs?.getString(KEY_BACKEND_PASS, null)
|
||||
|
||||
/** 加密保存远端后端 passphrase。 */
|
||||
fun setBackendPass(pass: String?) {
|
||||
if (pass == null) {
|
||||
prefs?.edit()?.remove(KEY_BACKEND_PASS)?.apply()
|
||||
} else {
|
||||
prefs?.edit()?.putString(KEY_BACKEND_PASS, pass)?.apply()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 状态检查 ─────────────────────────────────────
|
||||
|
||||
/** 检查密码管理器是否已初始化。 */
|
||||
fun isInitialized(): Boolean = prefs != null
|
||||
|
||||
/** 检查 restic 密码是否已设置。 */
|
||||
fun hasResticPassword(): Boolean = getResticPassword() != null
|
||||
|
||||
/** 清除所有存储的凭据。 */
|
||||
fun clearAll() {
|
||||
prefs?.edit()?.clear()?.apply()
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,26 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* Backup operations: running restic backup and parsing its summary output.
|
||||
*
|
||||
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||
* [RestBridgeRunner] which are shared across sub-modules.
|
||||
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
|
||||
*/
|
||||
class ResticBackup(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val bridgeRunner: RestBridgeRunner
|
||||
private val bridgeRunner: RestBridgeRunner,
|
||||
private val executor: BackendExecutor = BackendExecutor(),
|
||||
) {
|
||||
private val TAG = "ResticBackup"
|
||||
var cacheDir: String = ""
|
||||
@@ -40,105 +39,53 @@ class ResticBackup(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
|
||||
): AppResult<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {},
|
||||
): AppResult<ResticWrapper.BackupSummary> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
|
||||
if (backend == "local") {
|
||||
val args = mutableListOf("backup", "--json")
|
||||
for (path in paths) args.add(path)
|
||||
for (tag in tags) { args.add("--tag"); args.add(tag) }
|
||||
if (hostname != null) { args.add("--host"); args.add(hostname) }
|
||||
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runResticStreaming(env, args) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (e: Exception) { if (e is CancellationException) throw e }
|
||||
for (tag in tags) {
|
||||
args.add("--tag")
|
||||
args.add(tag)
|
||||
}
|
||||
if (hostname != null) {
|
||||
args.add("--host")
|
||||
args.add(hostname)
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) return@withContext err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
|
||||
parseBackupSummary(result.stdout)
|
||||
} else {
|
||||
bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl, authToken ->
|
||||
val args = mutableListOf("backup", "--json")
|
||||
for (path in paths) args.add(path)
|
||||
for (tag in tags) { args.add("--tag"); args.add(tag) }
|
||||
if (hostname != null) { args.add("--host"); args.add(hostname) }
|
||||
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
val result = runner.runResticStreaming(env, args) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (e: Exception) { if (e is CancellationException) throw e }
|
||||
val result =
|
||||
executor.withBackend(
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
runner = runner,
|
||||
envResolver = envResolver,
|
||||
bridgeRunner = bridgeRunner,
|
||||
) { env ->
|
||||
runner.runResticStreaming(env, args) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) return@withBridge err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
|
||||
parseBackupSummary(result.stdout)
|
||||
if (result.exitCode != 0) {
|
||||
return@withContext err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Streaming backup (stdin) ──────────────────────
|
||||
|
||||
/**
|
||||
* Run restic backup in --stdin mode, reading tar data from [stdinFile] (FIFO).
|
||||
* [extraPaths] are files/directories backed up alongside the streaming data
|
||||
* (e.g. APK paths, metadata directory).
|
||||
*/
|
||||
suspend fun backupStdin(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
stdinFile: File,
|
||||
extraPaths: List<String>,
|
||||
tags: List<String> = emptyList(),
|
||||
hostname: String? = null,
|
||||
backend: String = "local",
|
||||
backendUrl: String = "",
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
|
||||
): AppResult<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
|
||||
val args = mutableListOf("backup", "--json", "--stdin", "--stdin-filename", "app_data.tar")
|
||||
for (path in extraPaths) args.add(path)
|
||||
for (tag in tags) { args.add("--tag"); args.add(tag) }
|
||||
if (hostname != null) { args.add("--host"); args.add(hostname) }
|
||||
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runResticWithStdin(env, args, stdinFile) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticWithStdin
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (e: Exception) { if (e is CancellationException) throw e }
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) return@withContext err(AppError.Restic("restic stream backup 失败", result.exitCode, result.stderr))
|
||||
parseBackupSummary(result.stdout)
|
||||
} else {
|
||||
bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl, authToken ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
val result = runner.runResticWithStdin(env, args, stdinFile) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticWithStdin
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (e: Exception) { if (e is CancellationException) throw e }
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) return@withBridge err(AppError.Restic("restic stream backup 失败", result.exitCode, result.stderr))
|
||||
parseBackupSummary(result.stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal helpers ───────────────────────────────
|
||||
|
||||
@@ -151,7 +98,9 @@ class ResticBackup(
|
||||
try {
|
||||
val summary = resticJson.decodeFromString<ResticWrapper.BackupSummary>(line)
|
||||
if (summary.messageType == "summary" && summary.snapshotId.isNotEmpty()) return AppResult.Success(summary)
|
||||
} catch (_: Exception) { /* keep looking */ }
|
||||
} catch (_: Exception) {
|
||||
// keep looking
|
||||
}
|
||||
}
|
||||
return err(AppError.Parse("restic 备份输出未找到摘要信息", "stdout=" + stdout.length))
|
||||
}
|
||||
|
||||
@@ -118,6 +118,18 @@ class ResticCommandRunner {
|
||||
pb.redirectErrorStream(false)
|
||||
process = pb.start()
|
||||
|
||||
// Drain stderr on a separate daemon thread to avoid a pipe deadlock:
|
||||
// if stderr's buffer fills while we're still reading stdout, the child
|
||||
// process blocks on writing stderr and we block on reading stdout.
|
||||
var stderrBytes = byteArrayOf()
|
||||
val stderrThread = Thread {
|
||||
try {
|
||||
stderrBytes = process.errorStream.use { it.readAllBytesCompat() }
|
||||
} catch (_: Exception) {
|
||||
// stream closed early; leave stderrBytes empty
|
||||
}
|
||||
}.apply { isDaemon = true; start() }
|
||||
|
||||
val stdoutText = StringBuilder()
|
||||
val reader = process.inputStream.bufferedReader()
|
||||
|
||||
@@ -135,7 +147,7 @@ class ResticCommandRunner {
|
||||
} finally {
|
||||
try { reader.close() } catch (_: Exception) {}
|
||||
}
|
||||
val stderrBytes = try { process.errorStream.use { it.readAllBytesCompat() } } catch (_: Exception) { byteArrayOf() }
|
||||
try { stderrThread.join(1_000) } catch (_: InterruptedException) {}
|
||||
val stderrText = stderrBytes.decodeToString().trim()
|
||||
val exitCode = try {
|
||||
process.waitForCompat()
|
||||
@@ -153,80 +165,13 @@ class ResticCommandRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run restic with stdin redirected from [stdinFile] (FIFO or regular file).
|
||||
* Calls [onLine] for each stdout line (for streaming progress).
|
||||
*/
|
||||
suspend fun runResticWithStdin(
|
||||
env: Map<String, String>,
|
||||
args: List<String>,
|
||||
stdinFile: File,
|
||||
onLine: suspend (String) -> Unit
|
||||
): CommandResult = withContext(Dispatchers.IO) {
|
||||
val cmdArgs = buildCommandArgs(args)
|
||||
Log.i(TAG, "runResticWithStdin cmd=${cmdArgs.joinToString(" ")} stdin=${stdinFile.absolutePath}")
|
||||
Log.d(TAG, "runResticWithStdin REPOSITORY=${env["RESTIC_REPOSITORY"]}")
|
||||
env["TMPDIR"]?.let { File(it).mkdirs() }
|
||||
|
||||
var process: Process? = null
|
||||
try {
|
||||
|
||||
val pb = ProcessBuilder(cmdArgs)
|
||||
pb.environment().putAll(env)
|
||||
pb.redirectErrorStream(false)
|
||||
process = pb.start()
|
||||
|
||||
// Pipe stdin from file to process on a daemon thread (API 24 compat)
|
||||
Thread {
|
||||
try {
|
||||
val fis = java.io.FileInputStream(stdinFile)
|
||||
val pos = process!!.outputStream
|
||||
fis.use { input -> pos.use { output -> input.copyTo(output) } }
|
||||
} catch (_: Exception) {
|
||||
// FIFO writer closed; stdin pipe ends naturally
|
||||
}
|
||||
}.apply { isDaemon = true; start() }
|
||||
val stdoutText = StringBuilder()
|
||||
val reader = process.inputStream.bufferedReader()
|
||||
|
||||
try {
|
||||
var line = reader.readLine()
|
||||
while (line != null) {
|
||||
if (!coroutineContext.isActive) {
|
||||
process.destroy()
|
||||
break
|
||||
}
|
||||
stdoutText.appendLine(line)
|
||||
onLine(line)
|
||||
line = reader.readLine()
|
||||
}
|
||||
} finally {
|
||||
try { reader.close() } catch (_: Exception) {}
|
||||
}
|
||||
val stderrBytes = try { process.errorStream.use { it.readAllBytesCompat() } } catch (_: Exception) { byteArrayOf() }
|
||||
val stderrText = stderrBytes.decodeToString().trim()
|
||||
val exitCode = try {
|
||||
process.waitForCompat()
|
||||
} catch (_: Exception) { -1 }
|
||||
|
||||
Log.i(TAG, "runResticWithStdin exitCode=$exitCode stdout_len=${stdoutText.length}")
|
||||
if (stderrText.isNotEmpty()) Log.w(TAG, "runResticWithStdin stderr: ${stderrText}")
|
||||
CommandResult(stdoutText.toString().trim(), stderrText.trim(), exitCode)
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "runResticWithStdin exception", e)
|
||||
try { process?.destroy() } catch (_: Exception) {}
|
||||
CommandResult("", e.message ?: "Unknown error", -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compat implementation of InputStream.readAllBytes() for API < 33.
|
||||
* Reads the entire stream into a byte array.
|
||||
*/
|
||||
private fun InputStream.readAllBytesCompat(): ByteArray {
|
||||
internal fun InputStream.readAllBytesCompat(): ByteArray {
|
||||
val buffer = ByteArrayOutputStream()
|
||||
val data = ByteArray(4096)
|
||||
while (true) {
|
||||
|
||||
@@ -4,16 +4,15 @@ package com.example.androidbackupgui.backup
|
||||
* Stateless helper for constructing restic environment variables and repo URLs.
|
||||
*/
|
||||
class ResticEnvResolver {
|
||||
|
||||
|
||||
/** Build environment for non-local backends using the REST bridge URL. */
|
||||
fun buildBridgeEnv(
|
||||
password: String,
|
||||
bridgeUrl: String,
|
||||
cacheDir: String,
|
||||
authToken: String = ""
|
||||
authToken: String = "",
|
||||
): Map<String, String> {
|
||||
val env = HashMap(System.getenv() ?: emptyMap())
|
||||
// 从空白环境开始,不继承系统环境变量(防止敏感信息泄露到子进程)
|
||||
val env = HashMap<String, String>()
|
||||
env["RESTIC_REPOSITORY"] = bridgeUrl
|
||||
env["RESTIC_PASSWORD"] = password
|
||||
if (authToken.isNotEmpty()) {
|
||||
@@ -33,9 +32,10 @@ class ResticEnvResolver {
|
||||
fun buildLocalEnv(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
cacheDir: String
|
||||
cacheDir: String,
|
||||
): Map<String, String> {
|
||||
val env = HashMap(System.getenv() ?: emptyMap())
|
||||
// 从空白环境开始,不继承系统环境变量
|
||||
val env = HashMap<String, String>()
|
||||
env["RESTIC_REPOSITORY"] = repoPath
|
||||
env["RESTIC_PASSWORD"] = password
|
||||
if (cacheDir.isNotEmpty()) {
|
||||
@@ -48,13 +48,16 @@ class ResticEnvResolver {
|
||||
}
|
||||
|
||||
/** Build a display-friendly repository URL for UI. */
|
||||
fun buildRepoUrl(backend: String, repoPath: String, backendUrl: String): String {
|
||||
return when (backend) {
|
||||
fun buildRepoUrl(
|
||||
backend: String,
|
||||
repoPath: String,
|
||||
backendUrl: String,
|
||||
): String =
|
||||
when (backend) {
|
||||
"local" -> repoPath
|
||||
"rest-server" -> "rest:${backendUrl.trimEnd('/')}/$repoPath"
|
||||
"webdav" -> "${backendUrl.trimEnd('/')}/$repoPath"
|
||||
"smb" -> "smb:${backendUrl.trimEnd('/')}/$repoPath"
|
||||
else -> repoPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Repository maintenance operations: prune, check, stats.
|
||||
* Repository maintenance operations: prune, unlock, check, stats.
|
||||
*
|
||||
* [prune] requires both download and upload (it removes pack files from the remote).
|
||||
* [check] and [stats] are download-only read operations.
|
||||
*
|
||||
* For remote backends, uses [RestBridgeRunner] to serve the backend via REST,
|
||||
* so restic always sees a local rest-server repository. For local backends,
|
||||
* operates directly on the repo path.
|
||||
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
|
||||
*
|
||||
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||
* [RestBridgeRunner] which are shared across sub-modules.
|
||||
@@ -23,7 +20,8 @@ import java.io.File
|
||||
class ResticMaintenance(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val bridgeRunner: RestBridgeRunner
|
||||
private val bridgeRunner: RestBridgeRunner,
|
||||
private val executor: BackendExecutor = BackendExecutor(),
|
||||
) {
|
||||
/** Cache directory for restic env and bridge temp files. Set by [ResticWrapper]. */
|
||||
var cacheDir: String = ""
|
||||
@@ -31,7 +29,41 @@ class ResticMaintenance(
|
||||
/** SMB NTLM domain for remote backend. Set by [ResticWrapper]. */
|
||||
var backendDomain: String = ""
|
||||
|
||||
// ── Prune ──────────────────────────────────────────
|
||||
/** Run a one-shot restic command and map the result. */
|
||||
private suspend fun runCommand(
|
||||
command: String,
|
||||
failMessage: String,
|
||||
repoPath: String,
|
||||
password: String,
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
): AppResult<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val result =
|
||||
executor.runResticWithBackend(
|
||||
args = listOf(command),
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
runner = runner,
|
||||
envResolver = envResolver,
|
||||
bridgeRunner = bridgeRunner,
|
||||
)
|
||||
if (result.exitCode == 0) {
|
||||
AppResult.Success(result.stdout)
|
||||
} else {
|
||||
err(AppError.Restic(failMessage, result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun prune(
|
||||
repoPath: String,
|
||||
@@ -42,26 +74,17 @@ class ResticMaintenance(
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, "prune")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl, authToken ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
val result = runner.runRestic(env, "prune")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unlock ──────────────────────────────────────────
|
||||
runCommand(
|
||||
"prune",
|
||||
"restic prune 失败",
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
suspend fun unlock(
|
||||
repoPath: String,
|
||||
@@ -72,26 +95,17 @@ class ResticMaintenance(
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, "unlock")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic unlock 失败", result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl, authToken ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
val result = runner.runRestic(env, "unlock")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic unlock 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check ──────────────────────────────────────────
|
||||
runCommand(
|
||||
"unlock",
|
||||
"restic unlock 失败",
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
suspend fun check(
|
||||
repoPath: String,
|
||||
@@ -102,26 +116,17 @@ class ResticMaintenance(
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, "check")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic check 失败", result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl, authToken ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
val result = runner.runRestic(env, "check")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic check 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stats ──────────────────────────────────────────
|
||||
runCommand(
|
||||
"check",
|
||||
"restic check 失败",
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
suspend fun stats(
|
||||
repoPath: String,
|
||||
@@ -132,22 +137,15 @@ class ResticMaintenance(
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, "stats")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic stats 失败", result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl, authToken ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
val result = runner.runRestic(env, "stats")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic stats 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
runCommand(
|
||||
"stats",
|
||||
"restic stats 失败",
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -21,12 +21,14 @@ import java.io.File
|
||||
class ResticRepoInit(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val bridgeRunner: RestBridgeRunner
|
||||
private val bridgeRunner: RestBridgeRunner,
|
||||
private val executor: BackendExecutor = BackendExecutor(),
|
||||
) {
|
||||
private val TAG = "ResticWrapper"
|
||||
|
||||
/** Cache directory for restic env and bridge temp files. Set by ResticWrapper. */
|
||||
var cacheDir: String = ""
|
||||
|
||||
/** NTLM domain for SMB authentication. Set by ResticWrapper. */
|
||||
var backendDomain: String = ""
|
||||
|
||||
@@ -42,18 +44,20 @@ class ResticRepoInit(
|
||||
backendShare: String = "",
|
||||
): AppResult<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
runInit(env)
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl, authToken ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
runInit(env)
|
||||
}
|
||||
}
|
||||
executor.withBackend(
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
runner = runner,
|
||||
envResolver = envResolver,
|
||||
bridgeRunner = bridgeRunner,
|
||||
) { env -> runInit(env) }
|
||||
}
|
||||
|
||||
/** Shared init logic: run restic init, verify on exitCode 1. */
|
||||
@@ -88,7 +92,7 @@ class ResticRepoInit(
|
||||
// Config exists but verification failed — diagnose the cause
|
||||
val detail = diagnoseInitFailure(verify.stderr)
|
||||
return err(
|
||||
AppError.Restic("仓库已存在但无法验证: $detail", verify.exitCode, verify.stderr)
|
||||
AppError.Restic("仓库已存在但无法验证: $detail", verify.exitCode, verify.stderr),
|
||||
)
|
||||
}
|
||||
return err(AppError.Restic("restic init 失败", result.exitCode, result.stderr))
|
||||
@@ -98,15 +102,15 @@ class ResticRepoInit(
|
||||
private fun isConfigExistsError(stderr: String): Boolean {
|
||||
val lower = stderr.lowercase()
|
||||
return lower.contains("already exists") ||
|
||||
lower.contains("config file already exists")
|
||||
lower.contains("config file already exists")
|
||||
}
|
||||
|
||||
/** Check if stderr indicates a stale repository lock. */
|
||||
private fun isLockError(stderr: String): Boolean {
|
||||
val lower = stderr.lowercase()
|
||||
return lower.contains("lock") ||
|
||||
lower.contains("unable to create") ||
|
||||
lower.contains("already locked")
|
||||
lower.contains("unable to create") ||
|
||||
lower.contains("already locked")
|
||||
}
|
||||
|
||||
/** Parse restic stderr to produce a user-facing diagnosis string. */
|
||||
@@ -114,25 +118,38 @@ class ResticRepoInit(
|
||||
val lower = stderr.lowercase()
|
||||
return when {
|
||||
lower.contains("wrong password") ||
|
||||
lower.contains("password is incorrect") ||
|
||||
lower.contains("unable to decrypt") ||
|
||||
lower.contains("wrong key") ||
|
||||
lower.contains("invalid password") ||
|
||||
lower.contains("decryption") -> "密码不正确,请确认仓库密码"
|
||||
lower.contains("key") && (lower.contains("not found") || lower.contains("missing")) ->
|
||||
lower.contains("password is incorrect") ||
|
||||
lower.contains("unable to decrypt") ||
|
||||
lower.contains("wrong key") ||
|
||||
lower.contains("invalid password") ||
|
||||
lower.contains("decryption") -> {
|
||||
"密码不正确,请确认仓库密码"
|
||||
}
|
||||
|
||||
lower.contains("key") && (lower.contains("not found") || lower.contains("missing")) -> {
|
||||
"密钥文件缺失,仓库可能已损坏"
|
||||
lower.contains("permission") || lower.contains("access denied") ->
|
||||
}
|
||||
|
||||
lower.contains("permission") || lower.contains("access denied") -> {
|
||||
"权限不足,请检查目录权限"
|
||||
lower.contains("not a directory") || lower.contains("no such file") ->
|
||||
}
|
||||
|
||||
lower.contains("not a directory") || lower.contains("no such file") -> {
|
||||
"仓库路径无效或不可访问"
|
||||
else -> "仓库可能已损坏或密码不正确(${stderr.take(200).trim()})"
|
||||
}
|
||||
|
||||
else -> {
|
||||
"仓库可能已损坏或密码不正确(${stderr.take(200).trim()})"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public URL helper ──────────────────────────────
|
||||
|
||||
/** Build a display-friendly repository URL for UI. */
|
||||
fun buildRepoUrl(backend: String, repoPath: String, backendUrl: String): String {
|
||||
return envResolver.buildRepoUrl(backend, repoPath, backendUrl)
|
||||
}
|
||||
fun buildRepoUrl(
|
||||
backend: String,
|
||||
repoPath: String,
|
||||
backendUrl: String,
|
||||
): String = envResolver.buildRepoUrl(backend, repoPath, backendUrl)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import kotlinx.serialization.json.Json
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* NanoHTTPD-based REST bridge implementing the restic REST backend API.
|
||||
*
|
||||
@@ -28,9 +29,8 @@ class ResticRestBridge(
|
||||
private val remoteBase: String,
|
||||
private val repoPath: String,
|
||||
private val cacheDir: File,
|
||||
private val authToken: String = ""
|
||||
private val authToken: String = "",
|
||||
) : NanoHTTPD("127.0.0.1", 0) {
|
||||
|
||||
private val TAG = "ResticRestBridge"
|
||||
|
||||
init {
|
||||
@@ -46,15 +46,19 @@ class ResticRestBridge(
|
||||
|
||||
// Auth check (defense-in-depth — bridge is already bound to 127.0.0.1)
|
||||
if (authToken.isNotEmpty()) {
|
||||
val expected = "Basic " + Base64.encodeToString(
|
||||
"$authToken:$authToken".toByteArray(Charsets.UTF_8),
|
||||
Base64.NO_WRAP
|
||||
)
|
||||
val expected =
|
||||
"Basic " +
|
||||
Base64.encodeToString(
|
||||
"$authToken:$authToken".toByteArray(Charsets.UTF_8),
|
||||
Base64.NO_WRAP,
|
||||
)
|
||||
val auth = headers["authorization"]
|
||||
if (auth != expected) {
|
||||
Log.w(TAG, "auth failed (got=${auth?.take(20)}..., expected=Basic $authToken)")
|
||||
return newFixedLengthResponse(
|
||||
Response.Status.UNAUTHORIZED, "text/plain", "Unauthorized"
|
||||
Response.Status.UNAUTHORIZED,
|
||||
"text/plain",
|
||||
"Unauthorized",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -68,7 +72,7 @@ class ResticRestBridge(
|
||||
newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR,
|
||||
"text/plain",
|
||||
e.message ?: "Internal error"
|
||||
e.message ?: "Internal error",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -78,28 +82,38 @@ class ResticRestBridge(
|
||||
uri: String,
|
||||
headers: Map<String, String>,
|
||||
params: Map<String, String>,
|
||||
session: IHTTPSession
|
||||
session: IHTTPSession,
|
||||
): Response {
|
||||
val path = uri.trimEnd('/')
|
||||
// Strip the repoPath prefix (/backup/...) from the URI so that type/name
|
||||
// parsing sees only the restic REST API segment.
|
||||
val stripPrefix = if (repoPath.isNotEmpty()) "/${repoPath.trim('/')}" else ""
|
||||
val strippedPath = if (stripPrefix.isNotEmpty() && path.startsWith(stripPrefix)) {
|
||||
path.removePrefix(stripPrefix).ifEmpty { "/" }
|
||||
} else {
|
||||
path
|
||||
}
|
||||
val strippedPath =
|
||||
if (stripPrefix.isNotEmpty() && path.startsWith(stripPrefix)) {
|
||||
path.removePrefix(stripPrefix).ifEmpty { "/" }
|
||||
} else {
|
||||
path
|
||||
}
|
||||
|
||||
// POST {path}?create=true -> mkdirs
|
||||
if (method == NanoHTTPD.Method.POST && params["create"] == "true") {
|
||||
return runBlocking {
|
||||
when (transport.mkdirs(remoteBase)) {
|
||||
is AppResult.Success -> newFixedLengthResponse(
|
||||
Response.Status.OK, "text/plain", ""
|
||||
)
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain", "mkdirs failed"
|
||||
)
|
||||
is AppResult.Success -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.OK,
|
||||
"text/plain",
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR,
|
||||
"text/plain",
|
||||
"mkdirs failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,11 +152,15 @@ class ResticRestBridge(
|
||||
}
|
||||
|
||||
// -- Config endpoints -------------------------------------------
|
||||
|
||||
/**
|
||||
* Stream body from session input to a temp file to avoid OOM on large blobs.
|
||||
* Returns the temp file (caller must delete).
|
||||
*/
|
||||
private fun streamBodyToFile(session: IHTTPSession, tmpDir: File): Result<File> {
|
||||
private fun streamBodyToFile(
|
||||
session: IHTTPSession,
|
||||
tmpDir: File,
|
||||
): Result<File> {
|
||||
val started = System.currentTimeMillis()
|
||||
return try {
|
||||
val tmpFile = File(tmpDir, "restic_blob_${UUID.randomUUID()}")
|
||||
@@ -162,7 +180,10 @@ class ResticRestBridge(
|
||||
remaining -= n
|
||||
}
|
||||
if (remaining > 0) {
|
||||
Log.w(TAG, "streamBodyToFile: body truncated, expected $contentLength bytes but got EOF after ${contentLength - remaining}")
|
||||
Log.w(
|
||||
TAG,
|
||||
"streamBodyToFile: body truncated, expected $contentLength bytes but got EOF after ${contentLength - remaining}",
|
||||
)
|
||||
}
|
||||
Unit
|
||||
} else {
|
||||
@@ -184,87 +205,147 @@ class ResticRestBridge(
|
||||
private fun handleConfig(
|
||||
method: NanoHTTPD.Method,
|
||||
headers: Map<String, String>,
|
||||
session: IHTTPSession
|
||||
): Response = runBlocking {
|
||||
val remotePath = "$remoteBase/config"
|
||||
when (method) {
|
||||
NanoHTTPD.Method.HEAD -> {
|
||||
when (val exists = transport.exists(remotePath)) {
|
||||
is AppResult.Success -> {
|
||||
if (exists.data) {
|
||||
val sizeResult = transport.fileSize(remotePath)
|
||||
val fileSize = if (sizeResult is AppResult.Success) sizeResult.data else 0L
|
||||
newFixedLengthResponse(
|
||||
Response.Status.OK, "application/octet-stream",
|
||||
ByteArrayInputStream(ByteArray(0)), fileSize
|
||||
)
|
||||
} else {
|
||||
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "")
|
||||
}
|
||||
}
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND, "text/plain", ""
|
||||
)
|
||||
}
|
||||
}
|
||||
NanoHTTPD.Method.GET -> {
|
||||
val tempFile = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
|
||||
try {
|
||||
when (transport.download(remotePath, tempFile.absolutePath)) {
|
||||
session: IHTTPSession,
|
||||
): Response =
|
||||
runBlocking {
|
||||
val remotePath = "$remoteBase/config"
|
||||
when (method) {
|
||||
NanoHTTPD.Method.HEAD -> {
|
||||
var configExists = false
|
||||
var configSize = 0L
|
||||
// 先试 exists,失败时回退到 download 确认(某些 SMB 实现 exists 可能假阴性)
|
||||
when (val exists = transport.exists(remotePath)) {
|
||||
is AppResult.Success -> {
|
||||
val data = tempFile.readBytes()
|
||||
newFixedLengthResponse(Response.Status.OK, "application/octet-stream", data.inputStream(), data.size.toLong())
|
||||
if (exists.data) {
|
||||
configExists = true
|
||||
val sizeResult = transport.fileSize(remotePath)
|
||||
if (sizeResult is AppResult.Success) configSize = sizeResult.data
|
||||
}
|
||||
}
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND, "text/plain", ""
|
||||
|
||||
is AppResult.Failure -> { /* fall through to download check */ }
|
||||
}
|
||||
if (!configExists) {
|
||||
// Fallback: try downloading the config file to confirm existence
|
||||
val tmp = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
|
||||
try {
|
||||
when (transport.download(remotePath, tmp.absolutePath)) {
|
||||
is AppResult.Success -> {
|
||||
configExists = true
|
||||
configSize = tmp.length()
|
||||
}
|
||||
|
||||
is AppResult.Failure -> { /* truly not found */ }
|
||||
}
|
||||
} finally {
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
if (configExists) {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.OK,
|
||||
"application/octet-stream",
|
||||
ByteArrayInputStream(ByteArray(0)),
|
||||
configSize,
|
||||
)
|
||||
} else {
|
||||
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "")
|
||||
}
|
||||
}
|
||||
|
||||
NanoHTTPD.Method.GET -> {
|
||||
val tempFile = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
|
||||
try {
|
||||
when (transport.download(remotePath, tempFile.absolutePath)) {
|
||||
is AppResult.Success -> {
|
||||
val data = tempFile.readBytes()
|
||||
newFixedLengthResponse(
|
||||
Response.Status.OK,
|
||||
"application/octet-stream",
|
||||
data.inputStream(),
|
||||
data.size.toLong(),
|
||||
)
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND,
|
||||
"text/plain",
|
||||
"",
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
NanoHTTPD.Method.POST -> {
|
||||
val tmpResult = streamBodyToFile(session, cacheDir)
|
||||
if (tmpResult.isFailure) {
|
||||
return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR,
|
||||
"text/plain",
|
||||
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}",
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
NanoHTTPD.Method.POST -> {
|
||||
val tmpResult = streamBodyToFile(session, cacheDir)
|
||||
if (tmpResult.isFailure) return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain",
|
||||
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}"
|
||||
)
|
||||
val tmpFile = tmpResult.getOrThrow()
|
||||
try {
|
||||
when (transport.upload(tmpFile.absolutePath, remotePath)) {
|
||||
is AppResult.Success -> newFixedLengthResponse(
|
||||
Response.Status.OK, "text/plain", ""
|
||||
)
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain", "upload failed"
|
||||
)
|
||||
val tmpFile = tmpResult.getOrThrow()
|
||||
try {
|
||||
when (transport.upload(tmpFile.absolutePath, remotePath)) {
|
||||
is AppResult.Success -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.OK,
|
||||
"text/plain",
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR,
|
||||
"text/plain",
|
||||
"upload failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
tmpFile.delete()
|
||||
}
|
||||
} finally {
|
||||
tmpFile.delete()
|
||||
}
|
||||
|
||||
else -> {
|
||||
newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "text/plain", "")
|
||||
}
|
||||
}
|
||||
else -> newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "text/plain", "")
|
||||
}
|
||||
}
|
||||
|
||||
// -- Blob listing -----------------------------------------------
|
||||
|
||||
private fun handleListBlobs(type: String): Response = runBlocking {
|
||||
val remoteDir = "$remoteBase/$type"
|
||||
when (val result = transport.listFiles(remoteDir)) {
|
||||
is AppResult.Success -> {
|
||||
val items = result.data
|
||||
val json = buildV2Json(items)
|
||||
newFixedLengthResponse(Response.Status.OK, "application/vnd.x.restic.rest.v2", json)
|
||||
private fun handleListBlobs(type: String): Response =
|
||||
runBlocking {
|
||||
val remoteDir = "$remoteBase/$type"
|
||||
when (val result = transport.listFiles(remoteDir)) {
|
||||
is AppResult.Success -> {
|
||||
val items = result.data
|
||||
val json = buildV2Json(items)
|
||||
newFixedLengthResponse(Response.Status.OK, "application/vnd.x.restic.rest.v2", json)
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND,
|
||||
"text/plain",
|
||||
"",
|
||||
)
|
||||
}
|
||||
}
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND, "text/plain", ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BlobEntry(val name: String, val size: Long)
|
||||
data class BlobEntry(
|
||||
val name: String,
|
||||
val size: Long,
|
||||
)
|
||||
|
||||
private fun buildV2Json(items: List<RemoteTransport.RemoteFileInfo>): String {
|
||||
val blobs = items.filter { !it.isDirectory }.map { BlobEntry(it.name, it.size) }
|
||||
@@ -273,130 +354,181 @@ class ResticRestBridge(
|
||||
|
||||
// -- Blob HEAD (exists + size) ----------------------------------
|
||||
|
||||
private fun handleHeadBlob(type: String, name: String): Response = runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
when (val result = transport.exists(remotePath)) {
|
||||
is AppResult.Success -> {
|
||||
if (result.data) {
|
||||
newFixedLengthResponse(Response.Status.OK, "application/octet-stream", "")
|
||||
} else {
|
||||
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "")
|
||||
private fun handleHeadBlob(
|
||||
type: String,
|
||||
name: String,
|
||||
): Response =
|
||||
runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
when (val result = transport.exists(remotePath)) {
|
||||
is AppResult.Success -> {
|
||||
if (result.data) {
|
||||
newFixedLengthResponse(Response.Status.OK, "application/octet-stream", "")
|
||||
} else {
|
||||
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "")
|
||||
}
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND,
|
||||
"text/plain",
|
||||
"",
|
||||
)
|
||||
}
|
||||
}
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND, "text/plain", ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Blob GET (download with optional Range) --------------------
|
||||
|
||||
private fun handleGetBlob(
|
||||
type: String,
|
||||
name: String,
|
||||
headers: Map<String, String>
|
||||
): Response = runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
// Use RandomAccessFile to avoid loading entire blob into memory
|
||||
val tempFile = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
|
||||
try {
|
||||
when (transport.download(remotePath, tempFile.absolutePath)) {
|
||||
is AppResult.Success -> {
|
||||
val rangeHeader = headers["range"]?.lowercase()
|
||||
headers: Map<String, String>,
|
||||
): Response =
|
||||
runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
// Use RandomAccessFile to avoid loading entire blob into memory
|
||||
val tempFile = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
|
||||
try {
|
||||
when (transport.download(remotePath, tempFile.absolutePath)) {
|
||||
is AppResult.Success -> {
|
||||
val rangeHeader = headers["range"]?.lowercase()
|
||||
|
||||
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
|
||||
// Range request — only works with known file size
|
||||
val fileLen = tempFile.length()
|
||||
val range = rangeHeader.removePrefix("bytes=").trim()
|
||||
val dashIdx = range.indexOf('-')
|
||||
val start = range.substring(0, if (dashIdx >= 0) dashIdx else range.length)
|
||||
.toLongOrNull() ?: 0L
|
||||
val end = if (dashIdx >= 0 && dashIdx + 1 < range.length) {
|
||||
range.substring(dashIdx + 1).toLongOrNull() ?: (fileLen - 1)
|
||||
} else {
|
||||
fileLen - 1
|
||||
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
|
||||
// Range request — only works with known file size
|
||||
val fileLen = tempFile.length()
|
||||
val range = rangeHeader.removePrefix("bytes=").trim()
|
||||
val dashIdx = range.indexOf('-')
|
||||
val start =
|
||||
range
|
||||
.substring(0, if (dashIdx >= 0) dashIdx else range.length)
|
||||
.toLongOrNull() ?: 0L
|
||||
val end =
|
||||
if (dashIdx >= 0 && dashIdx + 1 < range.length) {
|
||||
range.substring(dashIdx + 1).toLongOrNull() ?: (fileLen - 1)
|
||||
} else {
|
||||
fileLen - 1
|
||||
}
|
||||
|
||||
val actualEnd = minOf(end, fileLen - 1).coerceAtLeast(0)
|
||||
val actualStart = minOf(start, actualEnd).coerceAtLeast(0)
|
||||
val chunkSize = (actualEnd - actualStart + 1).toInt()
|
||||
val chunk = ByteArray(chunkSize)
|
||||
try {
|
||||
val raf = java.io.RandomAccessFile(tempFile, "r")
|
||||
raf.use {
|
||||
it.seek(actualStart)
|
||||
it.readFully(chunk)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR,
|
||||
"text/plain",
|
||||
"range read failed",
|
||||
)
|
||||
}
|
||||
|
||||
val response =
|
||||
newChunkedResponse(
|
||||
Response.Status.PARTIAL_CONTENT,
|
||||
"application/octet-stream",
|
||||
chunk.inputStream(),
|
||||
)
|
||||
response.addHeader("Content-Range", "bytes $actualStart-$actualEnd/$fileLen")
|
||||
response.addHeader("Content-Length", chunkSize.toString())
|
||||
return@runBlocking response
|
||||
}
|
||||
|
||||
val actualEnd = minOf(end, fileLen - 1).coerceAtLeast(0)
|
||||
val actualStart = minOf(start, actualEnd).coerceAtLeast(0)
|
||||
val chunkSize = (actualEnd - actualStart + 1).toInt()
|
||||
val chunk = ByteArray(chunkSize)
|
||||
try {
|
||||
val raf = java.io.RandomAccessFile(tempFile, "r")
|
||||
raf.use { it.seek(actualStart); it.readFully(chunk) }
|
||||
} catch (_: Exception) {
|
||||
return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain", "range read failed"
|
||||
// Full file — read into memory (blobs are typically small)
|
||||
val data = tempFile.readBytes()
|
||||
val response =
|
||||
newChunkedResponse(
|
||||
Response.Status.OK,
|
||||
"application/octet-stream",
|
||||
data.inputStream(),
|
||||
)
|
||||
}
|
||||
|
||||
val response = newChunkedResponse(
|
||||
Response.Status.PARTIAL_CONTENT,
|
||||
"application/octet-stream",
|
||||
chunk.inputStream()
|
||||
)
|
||||
response.addHeader("Content-Range", "bytes $actualStart-$actualEnd/$fileLen")
|
||||
response.addHeader("Content-Length", chunkSize.toString())
|
||||
return@runBlocking response
|
||||
response.addHeader("Content-Length", data.size.toString())
|
||||
response
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND,
|
||||
"text/plain",
|
||||
"",
|
||||
)
|
||||
}
|
||||
// Full file — read into memory (blobs are typically small)
|
||||
val data = tempFile.readBytes()
|
||||
val response = newChunkedResponse(
|
||||
Response.Status.OK,
|
||||
"application/octet-stream",
|
||||
data.inputStream()
|
||||
)
|
||||
response.addHeader("Content-Length", data.size.toString())
|
||||
response
|
||||
}
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND, "text/plain", ""
|
||||
)
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
// -- Blob POST (upload) -----------------------------------------
|
||||
|
||||
private fun handlePostBlob(
|
||||
type: String,
|
||||
name: String,
|
||||
session: IHTTPSession
|
||||
): Response = runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
val tmpResult = streamBodyToFile(session, cacheDir)
|
||||
if (tmpResult.isFailure) return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain",
|
||||
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}"
|
||||
)
|
||||
val tmpFile = tmpResult.getOrThrow()
|
||||
try {
|
||||
when (transport.upload(tmpFile.absolutePath, remotePath)) {
|
||||
is AppResult.Success -> newFixedLengthResponse(
|
||||
Response.Status.OK, "text/plain", ""
|
||||
)
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain", "upload failed"
|
||||
session: IHTTPSession,
|
||||
): Response =
|
||||
runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
val tmpResult = streamBodyToFile(session, cacheDir)
|
||||
if (tmpResult.isFailure) {
|
||||
return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR,
|
||||
"text/plain",
|
||||
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}",
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
tmpFile.delete()
|
||||
val tmpFile = tmpResult.getOrThrow()
|
||||
try {
|
||||
when (transport.upload(tmpFile.absolutePath, remotePath)) {
|
||||
is AppResult.Success -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.OK,
|
||||
"text/plain",
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR,
|
||||
"text/plain",
|
||||
"upload failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
tmpFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Blob DELETE ------------------------------------------------
|
||||
|
||||
private fun handleDeleteBlob(type: String, name: String): Response = runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
when (transport.delete(remotePath)) {
|
||||
is AppResult.Success -> newFixedLengthResponse(
|
||||
Response.Status.OK, "text/plain", ""
|
||||
)
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain", "delete failed"
|
||||
)
|
||||
private fun handleDeleteBlob(
|
||||
type: String,
|
||||
name: String,
|
||||
): Response =
|
||||
runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
when (transport.delete(remotePath)) {
|
||||
is AppResult.Success -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.OK,
|
||||
"text/plain",
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR,
|
||||
"text/plain",
|
||||
"delete failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,31 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* Restore operations: full directory restore and single-file dump.
|
||||
*
|
||||
* Both are download-only operations (no upload to remote needed).
|
||||
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||
* [RestBridgeRunner] which are shared across sub-modules.
|
||||
*
|
||||
* @property cacheDir Cache directory for restic env and bridge temp files; set by [ResticWrapper].
|
||||
* @property backendDomain Domain for SMB NTLM authentication; set by [ResticWrapper].
|
||||
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
|
||||
*/
|
||||
class ResticRestore(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val bridgeRunner: RestBridgeRunner
|
||||
private val bridgeRunner: RestBridgeRunner,
|
||||
private val executor: BackendExecutor = BackendExecutor(),
|
||||
) {
|
||||
/** Cache directory for restic env and bridge temp files. Set by [ResticWrapper]. */
|
||||
var cacheDir: String = ""
|
||||
|
||||
/** Domain for SMB NTLM authentication. Set by [ResticWrapper]. */
|
||||
var backendDomain: String = ""
|
||||
|
||||
// ── Restore ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Restore a snapshot to [targetPath], optionally filtered by [include] pattern.
|
||||
*
|
||||
* For local backends, builds env via [ResticEnvResolver.buildLocalEnv] and runs
|
||||
* restic restore directly. For remote backends, proxies through [RestBridgeRunner]
|
||||
* using a local REST bridge, building env via [ResticEnvResolver.buildBridgeEnv].
|
||||
*/
|
||||
suspend fun restore(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
@@ -52,77 +37,63 @@ class ResticRestore(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onProgress: suspend (String) -> Unit = {}
|
||||
): AppResult<Unit> = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (String) -> Unit = { s -> withContext(Dispatchers.Main) { onProgress(s) } }
|
||||
|
||||
if (backend == "local") {
|
||||
onProgress: suspend (String) -> Unit = {},
|
||||
): AppResult<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val emit: suspend (String) -> Unit = { s -> withContext(Dispatchers.Main) { onProgress(s) } }
|
||||
File(targetPath).mkdirs()
|
||||
|
||||
val args = mutableListOf("restore", snapshotId, "--target", targetPath, "--json")
|
||||
if (include != null) { args.add("--include"); args.add(include) }
|
||||
if (include != null) {
|
||||
args.add("--include")
|
||||
args.add(include)
|
||||
}
|
||||
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runResticStreaming(env, args) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
when (progress.messageType) {
|
||||
"status" -> {
|
||||
val percent = "%.1f".format(progress.percentDone * 100)
|
||||
emit("恢复进度: $percent%")
|
||||
}
|
||||
"summary" -> {
|
||||
emit("恢复完成: ${progress.totalFiles} 个文件")
|
||||
val result =
|
||||
executor.withBackend(
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
runner = runner,
|
||||
envResolver = envResolver,
|
||||
bridgeRunner = bridgeRunner,
|
||||
) { env ->
|
||||
runner.runResticStreaming(env, args) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
when (progress.messageType) {
|
||||
"status" -> {
|
||||
val percent = "%.1f".format(progress.percentDone * 100)
|
||||
emit("恢复进度: $percent%")
|
||||
}
|
||||
|
||||
"summary" -> {
|
||||
emit("恢复完成: ${progress.totalFiles} 个文件")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
emit(line)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) { if (e is CancellationException) throw e; emit(line) }
|
||||
}
|
||||
|
||||
if (result.exitCode == 0) AppResult.Success(Unit)
|
||||
else err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare, backendDomain,
|
||||
repoPath, File(cacheDir)
|
||||
) { bridgeUrl, authToken ->
|
||||
File(targetPath).mkdirs()
|
||||
|
||||
val args = mutableListOf("restore", snapshotId, "--target", targetPath, "--json")
|
||||
if (include != null) { args.add("--include"); args.add(include) }
|
||||
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
val result = runner.runResticStreaming(env, args) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
when (progress.messageType) {
|
||||
"status" -> {
|
||||
val percent = "%.1f".format(progress.percentDone * 100)
|
||||
emit("恢复进度: $percent%")
|
||||
}
|
||||
"summary" -> {
|
||||
emit("恢复完成: ${progress.totalFiles} 个文件")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) { if (e is CancellationException) throw e; emit(line) }
|
||||
}
|
||||
|
||||
if (result.exitCode == 0) AppResult.Success(Unit)
|
||||
else err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr))
|
||||
if (result.exitCode == 0) {
|
||||
AppResult.Success(Unit)
|
||||
} else {
|
||||
err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── File dump ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dump the contents of a single file from a snapshot.
|
||||
*
|
||||
* For local backends, builds env via [ResticEnvResolver.buildLocalEnv] and runs
|
||||
* restic dump directly. For remote backends, proxies through [RestBridgeRunner]
|
||||
* using a local REST bridge, building env via [ResticEnvResolver.buildBridgeEnv].
|
||||
*/
|
||||
suspend fun dump(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
@@ -132,23 +103,29 @@ class ResticRestore(
|
||||
backendUrl: String = "",
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = ""
|
||||
): AppResult<String> = withContext(Dispatchers.IO) {
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, "dump", snapshotId, filePath)
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare, backendDomain,
|
||||
repoPath, File(cacheDir)
|
||||
) { bridgeUrl, authToken ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
val result = runner.runRestic(env, "dump", snapshotId, filePath)
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))
|
||||
backendShare: String = "",
|
||||
): AppResult<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val result =
|
||||
executor.withBackend(
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
runner = runner,
|
||||
envResolver = envResolver,
|
||||
bridgeRunner = bridgeRunner,
|
||||
) { env -> runner.runRestic(env, "dump", snapshotId, filePath) }
|
||||
|
||||
if (result.exitCode == 0) {
|
||||
AppResult.Success(result.stdout)
|
||||
} else {
|
||||
err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,25 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
import java.io.File
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Snapshot listing and retention policy operations.
|
||||
*
|
||||
* [listSnapshots] is download-only; [forget] removes snapshots from the remote.
|
||||
*
|
||||
* For "local" backends, invokes restic directly against [repoPath].
|
||||
* For remote backends (SMB/WebDAV/rest-server), starts a temporary REST bridge
|
||||
* via [RestBridgeRunner.withBridge] and points restic at the bridge URL.
|
||||
*
|
||||
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||
* [RestBridgeRunner] which are shared across sub-modules.
|
||||
* 使用 [BackendExecutor] 统一处理 local/remote 后端。
|
||||
*/
|
||||
class ResticSnapshotOps(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val bridgeRunner: RestBridgeRunner
|
||||
private val bridgeRunner: RestBridgeRunner,
|
||||
private val executor: BackendExecutor = BackendExecutor(),
|
||||
) {
|
||||
/** Cache directory for restic env and bridge temp files. Set by ResticWrapper. */
|
||||
var cacheDir: String = ""
|
||||
/** NTLM domain for SMB authentication. Set by ResticWrapper. */
|
||||
var backendDomain: String = ""
|
||||
|
||||
// ── List snapshots ─────────────────────────────────
|
||||
@@ -41,52 +33,44 @@ class ResticSnapshotOps(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<List<ResticWrapper.ResticSnapshot>> = withContext(Dispatchers.IO) {
|
||||
if (backend == "local") {
|
||||
): AppResult<List<ResticWrapper.ResticSnapshot>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val args = mutableListOf("snapshots", "--json")
|
||||
if (tag != null) { args.add("--tag"); args.add(tag) }
|
||||
if (tag != null) {
|
||||
args.add("--tag")
|
||||
args.add(tag)
|
||||
}
|
||||
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, args)
|
||||
val result =
|
||||
executor.withBackend(
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
runner = runner,
|
||||
envResolver = envResolver,
|
||||
bridgeRunner = bridgeRunner,
|
||||
) { env -> runner.runRestic(env, args) }
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
return@withContext err(AppError.Restic("restic snapshots 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
|
||||
try {
|
||||
val snapshots = resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
|
||||
result.stdout.ifEmpty { "[]" }
|
||||
)
|
||||
val snapshots =
|
||||
resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
|
||||
result.stdout.ifEmpty { "[]" },
|
||||
)
|
||||
AppResult.Success(snapshots.sortedByDescending { it.time })
|
||||
} catch (e: Exception) {
|
||||
err(AppError.Parse("解析快照 JSON 失败", e.message ?: ""))
|
||||
}
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl, authToken ->
|
||||
val args = mutableListOf("snapshots", "--json")
|
||||
if (tag != null) { args.add("--tag"); args.add(tag) }
|
||||
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
val result = runner.runRestic(env, args)
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
return@withBridge err(AppError.Restic("restic snapshots 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
|
||||
try {
|
||||
val snapshots = resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
|
||||
result.stdout.ifEmpty { "[]" }
|
||||
)
|
||||
AppResult.Success(snapshots.sortedByDescending { it.time })
|
||||
} catch (e: Exception) {
|
||||
err(AppError.Parse("解析快照 JSON 失败", e.message ?: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Forget (retention policy) ──────────────────────
|
||||
|
||||
@@ -102,40 +86,40 @@ class ResticSnapshotOps(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> = withContext(Dispatchers.IO) {
|
||||
if (backend == "local") {
|
||||
val args = mutableListOf(
|
||||
"forget",
|
||||
"--keep-daily", keepDaily.toString(),
|
||||
"--keep-weekly", keepWeekly.toString(),
|
||||
"--keep-monthly", keepMonthly.toString()
|
||||
)
|
||||
): AppResult<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val args =
|
||||
mutableListOf(
|
||||
"forget",
|
||||
"--keep-daily",
|
||||
keepDaily.toString(),
|
||||
"--keep-weekly",
|
||||
keepWeekly.toString(),
|
||||
"--keep-monthly",
|
||||
keepMonthly.toString(),
|
||||
)
|
||||
if (dryRun) args.add("--dry-run")
|
||||
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, args)
|
||||
val result =
|
||||
executor.withBackend(
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
runner = runner,
|
||||
envResolver = envResolver,
|
||||
bridgeRunner = bridgeRunner,
|
||||
) { env -> runner.runRestic(env, args) }
|
||||
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic forget 失败", result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl, authToken ->
|
||||
val args = mutableListOf(
|
||||
"forget",
|
||||
"--keep-daily", keepDaily.toString(),
|
||||
"--keep-weekly", keepWeekly.toString(),
|
||||
"--keep-monthly", keepMonthly.toString()
|
||||
)
|
||||
if (dryRun) args.add("--dry-run")
|
||||
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
val result = runner.runRestic(env, args)
|
||||
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic forget 失败", result.exitCode, result.stderr))
|
||||
if (result.exitCode == 0) {
|
||||
AppResult.Success(result.stdout)
|
||||
} else {
|
||||
err(AppError.Restic("restic forget 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* "流式"备份——将应用数据 tar 到临时目录,然后由 restic 统一备份。
|
||||
*
|
||||
* 原实现使用 FIFO + `restic backup --stdin`,但由于 RootShell 每次 exec
|
||||
* 会独立打开/关闭 FIFO,导致 restic 在第一次写入后收到 EOF 退出。
|
||||
*
|
||||
* 当前实现改为:
|
||||
* 1. 创建临时工作目录 stream_data/
|
||||
* 2. 将元数据 + APK 文件复制到该目录
|
||||
* 3. 对每个应用,tar 数据到该目录下的独立文件
|
||||
* 4. 运行 restic backup 指向该目录(无 --stdin,无 FIFO)
|
||||
* 5. 备份完成后清理临时目录
|
||||
*
|
||||
* 和普通备份的区别:临时目录会在备份完成后自动删除,不留本地存档。
|
||||
* 仅当 [BackupConfig.useStreaming] 启用时使用。
|
||||
*/
|
||||
object ResticStreamBackup {
|
||||
private const val TAG = "ResticStreamBackup"
|
||||
|
||||
/** 单个应用跳过备份的数据大小阈值(500MB) */
|
||||
private const val MAX_STREAM_APP_SIZE_BYTES = 500L * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Run a streaming backup.
|
||||
*/
|
||||
suspend fun backup(
|
||||
cacheDir: File,
|
||||
ownPackageName: String,
|
||||
apps: List<AppInfo>,
|
||||
noDataBackup: Set<String>,
|
||||
legacyApps: Map<String, ResticWrapper.SnapshotAppInfo>?,
|
||||
userId: String,
|
||||
restic: ResticWrapper,
|
||||
repoPath: String,
|
||||
password: String,
|
||||
tags: List<String>,
|
||||
hostname: String?,
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
onProgress: suspend (String) -> Unit = {},
|
||||
): AppResult<ResticWrapper.BackupSummary> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val emit: suspend (String) -> Unit = { msg -> withContext(Dispatchers.Main) { onProgress(msg) } }
|
||||
|
||||
// ── 1. Create temporary work directory ──────
|
||||
val workDir = File(cacheDir, "stream_data")
|
||||
if (workDir.exists()) RootShell.exec("rm -rf '${workDir.absolutePath.shellEscape()}'")
|
||||
workDir.mkdirs()
|
||||
Log.i(TAG, "Work dir created at ${workDir.absolutePath}")
|
||||
|
||||
try {
|
||||
// ── 2. Write metadata ─────────────────────
|
||||
// 文件直接放在 workDir 根下,与普通备份结构一致
|
||||
emit("正在准备元数据…")
|
||||
BackupOperation.writeFileForBackup(
|
||||
File(workDir, "appList.txt"),
|
||||
apps.joinToString("\n") { it.packageName.value },
|
||||
)
|
||||
BackupOperation.writeFileForBackup(
|
||||
File(workDir, "app_details.json"),
|
||||
BackupOperation.buildAppDetailsJson(apps, legacyApps),
|
||||
)
|
||||
Log.i(TAG, "Metadata written to ${workDir.absolutePath}")
|
||||
|
||||
// ── 3. Backup APK files ───────────────────
|
||||
// 统一使用 per-app 子目录结构,与普通备份和恢复代码兼容
|
||||
emit("正在备份 APK 文件…")
|
||||
var apkCount = 0
|
||||
for (app in apps) {
|
||||
if (!coroutineContext.isActive) return@withContext err(AppError.Cancelled)
|
||||
val appDir = File(workDir, app.packageName.value)
|
||||
appDir.mkdirs()
|
||||
val paths = AppScanner.getApkPaths(app.packageName.value)
|
||||
for ((i, apkPath) in paths.withIndex()) {
|
||||
val destName = if (paths.size > 1) "${app.packageName.value}_split_$i.apk" else "${app.packageName.value}.apk"
|
||||
val cpOk =
|
||||
RootShell
|
||||
.exec(
|
||||
"cp '${apkPath.shellEscape()}' '${File(appDir, destName).absolutePath.shellEscape()}' 2>/dev/null",
|
||||
).isSuccess
|
||||
if (cpOk) apkCount++
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "Backed up $apkCount APK files")
|
||||
|
||||
// ── 4. Backup app data ────────────────────
|
||||
var successCount = 0
|
||||
var failCount = 0
|
||||
|
||||
for ((index, app) in apps.withIndex()) {
|
||||
if (!coroutineContext.isActive) return@withContext err(AppError.Cancelled)
|
||||
|
||||
val pkgName = app.packageName.value
|
||||
if (pkgName in noDataBackup) {
|
||||
Log.d(TAG, "backup: skipping data for $pkgName (excluded)")
|
||||
continue
|
||||
}
|
||||
|
||||
emit("备份数据: $pkgName (${index + 1}/${apps.size})")
|
||||
|
||||
// Force-stop app before data backup for consistency
|
||||
if (pkgName !in listOf("bin.mt.plus", "com.termux", "bin.mt.plus.canary", ownPackageName)) {
|
||||
RootShell.exec("am force-stop --user $userId '$pkgName' 2>/dev/null")
|
||||
}
|
||||
|
||||
// Check data dirs exist
|
||||
val dirs = mutableListOf<String>()
|
||||
val dataCheck = RootShell.exec("test -d '/data/data/${pkgName.shellEscape()}' && echo 1 || echo 0")
|
||||
if (dataCheck.output.trim() == "1") dirs.add("/data/data/$pkgName")
|
||||
|
||||
val userDeCheck =
|
||||
RootShell.exec(
|
||||
"test -d '/data/user_de/${userId.shellEscape()}/${pkgName.shellEscape()}' && echo 1 || echo 0",
|
||||
)
|
||||
if (userDeCheck.output.trim() == "1") dirs.add("/data/user_de/$userId/$pkgName")
|
||||
|
||||
if (dirs.isEmpty()) {
|
||||
Log.d(TAG, "backup: no data dirs for $pkgName, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
// Estimate size, skip oversized apps
|
||||
val dirArgs = dirs.joinToString(" ") { "'${it.shellEscape()}'" }
|
||||
val preCheck =
|
||||
RootShell.exec(
|
||||
"du -sb --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' --exclude='.ota' $dirArgs 2>/dev/null | awk '{s+=\$1} END{print s}'",
|
||||
)
|
||||
val estimatedBytes = preCheck.output.trim().toLongOrNull() ?: 0L
|
||||
if (estimatedBytes > MAX_STREAM_APP_SIZE_BYTES) {
|
||||
emit("⚠ $pkgName 数据过大 (${estimatedBytes / 1024 / 1024}MB),跳过")
|
||||
Log.w(TAG, "backup: $pkgName too large (${estimatedBytes / 1024 / 1024}MB), skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
// Tar app data to per-app subdirectory
|
||||
val appDir = File(workDir, pkgName)
|
||||
appDir.mkdirs()
|
||||
val tarFile = File(appDir, "${pkgName}_data.tar.zst")
|
||||
// 使用系统 tar + 捆绑的 zstd(从 cacheDir 推导 filesDir)
|
||||
val filesDir = File(cacheDir.parentFile, "files")
|
||||
val zstdBin = File(File(filesDir, "bin"), "zstd_bin")
|
||||
val zstdCmd = if (zstdBin.canExecute()) zstdBin.absolutePath else "zstd"
|
||||
val tarCmd = "set -o pipefail; tar -cf - $dirArgs --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' --exclude='.ota' 2>/dev/null | $zstdCmd -T0 -o '${tarFile.absolutePath.shellEscape()}'"
|
||||
RootShell.exec("chmod +x '${zstdBin.absolutePath.shellEscape()}' 2>/dev/null")
|
||||
|
||||
val result = RootShell.exec(tarCmd)
|
||||
if (result.isSuccess && tarFile.length() > 0) {
|
||||
successCount++
|
||||
} else {
|
||||
Log.w(TAG, "backup: tar failed for $pkgName exit=${result.exitCode} err='${result.error.take(200)}'")
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
emit("数据备份完成 (成功 $successCount, 失败 $failCount),正在上传至 restic…")
|
||||
|
||||
// ── 5. Run restic backup ──────────────────
|
||||
val args = mutableListOf("backup", "--json")
|
||||
args.add(workDir.absolutePath)
|
||||
for (tag in tags) {
|
||||
args.add("--tag")
|
||||
args.add(tag)
|
||||
}
|
||||
if (hostname != null) {
|
||||
args.add("--host")
|
||||
args.add(hostname)
|
||||
}
|
||||
|
||||
val cmdArgs = restic.runner.buildCommandArgs(args)
|
||||
Log.i(TAG, "Running restic ${cmdArgs.joinToString(" ")}")
|
||||
|
||||
val result =
|
||||
restic.executor.runResticStreamingWithBackend(
|
||||
args = args,
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = restic.cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = restic.backendDomain,
|
||||
runner = restic.runner,
|
||||
envResolver = restic.envResolver,
|
||||
bridgeRunner = restic.bridgeRunner,
|
||||
onLine = { line ->
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") {
|
||||
val pct = "%.1f".format(progress.percentDone * 100)
|
||||
emit(
|
||||
"上传进度: $pct% (${progress.filesDone}/${progress.totalFiles} 文件, ${progress.bytesDone / 1024 / 1024}/${progress.totalBytes / 1024 / 1024}MB)",
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
if (line.length < 200) emit(line)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
Log.e(TAG, "restic backup failed: exit=${result.exitCode} stderr=${result.stderr.take(500)}")
|
||||
return@withContext err(AppError.Restic("restic 备份失败", result.exitCode, result.stderr))
|
||||
}
|
||||
|
||||
// ── 6. Parse summary ─────────────────────
|
||||
val summaryLine =
|
||||
result.stdout.lines().lastOrNull { line ->
|
||||
line.contains("\"message_type\"") && line.contains("\"summary\"")
|
||||
}
|
||||
val summary =
|
||||
if (summaryLine != null) {
|
||||
try {
|
||||
resticJson.decodeFromString<ResticWrapper.BackupSummary>(summaryLine)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse summary: ${e.message}")
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (summary == null) {
|
||||
return@withContext err(AppError.Parse("restic 未返回摘要信息", ""))
|
||||
}
|
||||
|
||||
// ── 7. Verify snapshot ───────────────────
|
||||
val snapshotId = summary.snapshotId
|
||||
emit("正在验证快照 ${snapshotId.take(8)}…")
|
||||
try {
|
||||
restic.executor.withBackend(
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
cacheDir = restic.cacheDir,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = restic.backendDomain,
|
||||
runner = restic.runner,
|
||||
envResolver = restic.envResolver,
|
||||
bridgeRunner = restic.bridgeRunner,
|
||||
) { env ->
|
||||
val verifyResult = restic.runner.runRestic(env, "snapshots", "--json")
|
||||
if (verifyResult.exitCode == 0 && verifyResult.stdout.contains(snapshotId)) {
|
||||
Log.i(TAG, "backup: snapshot $snapshotId verified")
|
||||
} else {
|
||||
Log.w(TAG, "backup: snapshot $snapshotId NOT found in snapshots list!")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "backup: snapshot verification failed: ${e.message}")
|
||||
}
|
||||
|
||||
AppResult.Success(summary)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(TAG, "backup failed: ${e.message}")
|
||||
err(AppError.Restic("流式备份异常: ${e.message}", -1, ""))
|
||||
} finally {
|
||||
// ── 8. Cleanup ───────────────────────────
|
||||
emit("正在清理临时文件…")
|
||||
RootShell.exec("rm -rf '${workDir.absolutePath.shellEscape()}'")
|
||||
Log.i(TAG, "Work dir cleaned up")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerialName
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* Wraps the restic CLI binary for backup/restore operations.
|
||||
@@ -30,28 +30,42 @@ import com.example.androidbackupgui.backup.err
|
||||
* ([ResticRepoInit], [ResticBackup], [ResticRestore], [ResticSnapshotOps],
|
||||
* [ResticMaintenance]).
|
||||
*/
|
||||
object ResticWrapper {
|
||||
|
||||
private const val TAG = "ResticWrapper"
|
||||
/**
|
||||
* 默认 [ResticWrapper] 实例。用于不需要自定义依赖注入的场景。
|
||||
*/
|
||||
val defaultResticWrapper: ResticWrapper = ResticWrapper()
|
||||
|
||||
private val runner = ResticCommandRunner()
|
||||
private val envResolver = ResticEnvResolver()
|
||||
private val bridgeRunner = RestBridgeRunner()
|
||||
/**
|
||||
* Wraps the restic CLI binary for backup/restore operations.
|
||||
*
|
||||
* 现在是一个 class 而非 object,可以通过构造函数注入依赖。
|
||||
* 使用 [defaultResticWrapper] 获取默认单例。
|
||||
*/
|
||||
class ResticWrapper(
|
||||
internal val runner: ResticCommandRunner = ResticCommandRunner(),
|
||||
internal val envResolver: ResticEnvResolver = ResticEnvResolver(),
|
||||
internal val bridgeRunner: RestBridgeRunner = RestBridgeRunner(),
|
||||
internal val executor: BackendExecutor = BackendExecutor(),
|
||||
) {
|
||||
private val TAG = "ResticWrapper"
|
||||
|
||||
// ── Sub-module instances ───────────────────────────
|
||||
|
||||
private val repoInit = ResticRepoInit(runner, envResolver, bridgeRunner)
|
||||
private val backupOp = ResticBackup(runner, envResolver, bridgeRunner)
|
||||
private val restoreOp = ResticRestore(runner, envResolver, bridgeRunner)
|
||||
private val snapshotOps = ResticSnapshotOps(runner, envResolver, bridgeRunner)
|
||||
private val maintenance = ResticMaintenance(runner, envResolver, bridgeRunner)
|
||||
private val repoInit = ResticRepoInit(runner, envResolver, bridgeRunner, executor)
|
||||
private val backupOp = ResticBackup(runner, envResolver, bridgeRunner, executor)
|
||||
private val restoreOp = ResticRestore(runner, envResolver, bridgeRunner, executor)
|
||||
private val snapshotOps = ResticSnapshotOps(runner, envResolver, bridgeRunner, executor)
|
||||
private val maintenance = ResticMaintenance(runner, envResolver, bridgeRunner, executor)
|
||||
|
||||
// ── Property delegation ───────────────────────────
|
||||
|
||||
/** Path to the restic binary. Default assumes it's on PATH (e.g. Termux). */
|
||||
var binaryPath: String
|
||||
get() = runner.binaryPath
|
||||
set(v) { runner.binaryPath = v }
|
||||
set(v) {
|
||||
runner.binaryPath = v
|
||||
}
|
||||
|
||||
/** Cache directory for restic (XDG_CACHE_HOME) and bridge tmp blobs. */
|
||||
var cacheDir: String = ""
|
||||
@@ -64,7 +78,6 @@ object ResticWrapper {
|
||||
maintenance.cacheDir = v
|
||||
}
|
||||
|
||||
|
||||
/** Domain for SMB NTLM authentication. Propagated to sub-modules. */
|
||||
var backendDomain: String = ""
|
||||
set(v) {
|
||||
@@ -79,13 +92,13 @@ object ResticWrapper {
|
||||
|
||||
@Serializable
|
||||
data class ResticProgress(
|
||||
@SerialName("message_type") val messageType: String, // "status" during backup
|
||||
@SerialName("message_type") val messageType: String, // "status" during backup
|
||||
@SerialName("percent_done") val percentDone: Double = 0.0,
|
||||
@SerialName("total_files") val totalFiles: Int = 0,
|
||||
@SerialName("files_done") val filesDone: Int = 0,
|
||||
@SerialName("total_bytes") val totalBytes: Long = 0,
|
||||
@SerialName("bytes_done") val bytesDone: Long = 0,
|
||||
@SerialName("current_files") val currentFiles: List<String> = emptyList()
|
||||
@SerialName("current_files") val currentFiles: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -95,14 +108,14 @@ object ResticWrapper {
|
||||
val time: String,
|
||||
val paths: List<String>,
|
||||
val tags: List<String>,
|
||||
val hostname: String = ""
|
||||
val hostname: String = "",
|
||||
)
|
||||
|
||||
/** App metadata read from a restic snapshot for change detection. */
|
||||
data class SnapshotAppInfo(
|
||||
val label: String,
|
||||
val isSystem: Boolean,
|
||||
val apkSizes: List<Long> = emptyList()
|
||||
val apkSizes: List<Long> = emptyList(),
|
||||
)
|
||||
|
||||
// ── Repository lifecycle ─────────────────────────
|
||||
@@ -115,9 +128,16 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<Unit> = repoInit.init(
|
||||
repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
): AppResult<Unit> =
|
||||
repoInit.init(
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
// ── Backup ─────────────────────────────────────────
|
||||
|
||||
@@ -136,7 +156,7 @@ object ResticWrapper {
|
||||
@SerialName("data_added") val dataAdded: Long = 0,
|
||||
@SerialName("total_files_processed") val totalFilesProcessed: Int = 0,
|
||||
@SerialName("total_bytes_processed") val totalBytesProcessed: Long = 0,
|
||||
@SerialName("total_duration") val totalDuration: Double = 0.0
|
||||
@SerialName("total_duration") val totalDuration: Double = 0.0,
|
||||
)
|
||||
|
||||
suspend fun backup(
|
||||
@@ -150,33 +170,62 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onProgress: suspend (ResticProgress) -> Unit = {}
|
||||
): AppResult<BackupSummary> = backupOp.backup(
|
||||
repoPath, password, paths, tags, hostname,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onProgress
|
||||
)
|
||||
onProgress: suspend (ResticProgress) -> Unit = {},
|
||||
): AppResult<BackupSummary> =
|
||||
backupOp.backup(
|
||||
repoPath,
|
||||
password,
|
||||
paths,
|
||||
tags,
|
||||
hostname,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
onProgress,
|
||||
)
|
||||
|
||||
// ── Streaming backup (stdin) ─────────────────────
|
||||
|
||||
suspend fun backupStdin(
|
||||
/**
|
||||
* Streaming backup: pipes tar data through a FIFO directly into restic --stdin.
|
||||
* Avoids writing a staging tarball to disk. Requires [cacheDir] to be set first.
|
||||
*/
|
||||
suspend fun backupStreaming(
|
||||
apps: List<AppInfo>,
|
||||
noDataBackup: Set<String>,
|
||||
legacyApps: Map<String, SnapshotAppInfo>?,
|
||||
userId: String = "0",
|
||||
repoPath: String,
|
||||
password: String,
|
||||
stdinFile: File,
|
||||
extraPaths: List<String>,
|
||||
tags: List<String> = emptyList(),
|
||||
hostname: String? = null,
|
||||
backend: String = "local",
|
||||
backendUrl: String = "",
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onProgress: suspend (ResticProgress) -> Unit = {}
|
||||
): AppResult<BackupSummary> = backupOp.backupStdin(
|
||||
repoPath, password, stdinFile, extraPaths, tags, hostname,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onProgress
|
||||
)
|
||||
tags: List<String>,
|
||||
hostname: String?,
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
onProgress: suspend (String) -> Unit = {},
|
||||
ownPackageName: String = "",
|
||||
): AppResult<BackupSummary> =
|
||||
ResticStreamBackup.backup(
|
||||
cacheDir = File(cacheDir),
|
||||
ownPackageName = ownPackageName,
|
||||
apps = apps,
|
||||
noDataBackup = noDataBackup,
|
||||
legacyApps = legacyApps,
|
||||
userId = userId,
|
||||
restic = this,
|
||||
repoPath = repoPath,
|
||||
password = password,
|
||||
tags = tags,
|
||||
hostname = hostname,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
onProgress = onProgress,
|
||||
)
|
||||
|
||||
// ── Restore ────────────────────────────────────────
|
||||
|
||||
@@ -191,12 +240,21 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onProgress: suspend (String) -> Unit = {}
|
||||
): AppResult<Unit> = restoreOp.restore(
|
||||
repoPath, password, snapshotId, targetPath, include,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onProgress
|
||||
)
|
||||
onProgress: suspend (String) -> Unit = {},
|
||||
): AppResult<Unit> =
|
||||
restoreOp.restore(
|
||||
repoPath,
|
||||
password,
|
||||
snapshotId,
|
||||
targetPath,
|
||||
include,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
onProgress,
|
||||
)
|
||||
|
||||
// ── File dump ──────────────────────────────────────
|
||||
|
||||
@@ -210,10 +268,18 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> = restoreOp.dump(
|
||||
repoPath, password, snapshotId, filePath,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
): AppResult<String> =
|
||||
restoreOp.dump(
|
||||
repoPath,
|
||||
password,
|
||||
snapshotId,
|
||||
filePath,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
// ── Snapshot management ────────────────────────────
|
||||
|
||||
@@ -226,10 +292,17 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<List<ResticSnapshot>> = snapshotOps.listSnapshots(
|
||||
repoPath, password, tag,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
): AppResult<List<ResticSnapshot>> =
|
||||
snapshotOps.listSnapshots(
|
||||
repoPath,
|
||||
password,
|
||||
tag,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
suspend fun forget(
|
||||
repoPath: String,
|
||||
@@ -243,10 +316,20 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> = snapshotOps.forget(
|
||||
repoPath, password, keepDaily, keepWeekly, keepMonthly, dryRun,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
): AppResult<String> =
|
||||
snapshotOps.forget(
|
||||
repoPath,
|
||||
password,
|
||||
keepDaily,
|
||||
keepWeekly,
|
||||
keepMonthly,
|
||||
dryRun,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
/**
|
||||
* Read [app_details.json] from the latest restic snapshot and return a map
|
||||
@@ -261,37 +344,63 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): Map<String, SnapshotAppInfo>? = withContext(Dispatchers.IO) {
|
||||
val snapsResult = snapshotOps.listSnapshots(
|
||||
repoPath, password, tag = null,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
val snaps = when (snapsResult) {
|
||||
is AppResult.Failure -> {
|
||||
Log.w(TAG, "getLatestSnapshotAppDetails: listSnapshots failed: ${snapsResult.error.message}")
|
||||
null
|
||||
}
|
||||
is AppResult.Success -> snapsResult.data
|
||||
} ?: return@withContext null
|
||||
): Map<String, SnapshotAppInfo>? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val snapsResult =
|
||||
snapshotOps.listSnapshots(
|
||||
repoPath,
|
||||
password,
|
||||
tag = null,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
val snaps =
|
||||
when (snapsResult) {
|
||||
is AppResult.Failure -> {
|
||||
Log.w(TAG, "getLatestSnapshotAppDetails: listSnapshots failed: ${snapsResult.error.message}")
|
||||
null
|
||||
}
|
||||
|
||||
if (snaps.isEmpty()) return@withContext null
|
||||
is AppResult.Success -> {
|
||||
snapsResult.data
|
||||
}
|
||||
} ?: return@withContext null
|
||||
|
||||
val latestId = snaps.first().shortId
|
||||
val basePath = snaps.first().paths.firstOrNull()?.trimEnd('/') ?: return@withContext null
|
||||
if (snaps.isEmpty()) return@withContext null
|
||||
|
||||
val dumpResult = restoreOp.dump(
|
||||
repoPath, password, latestId, "$basePath/app_details.json",
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
val latestId = snaps.first().shortId
|
||||
val basePath =
|
||||
snaps
|
||||
.first()
|
||||
.paths
|
||||
.firstOrNull()
|
||||
?.trimEnd('/') ?: return@withContext null
|
||||
|
||||
val jsonStr = when (dumpResult) {
|
||||
is AppResult.Failure -> return@withContext null
|
||||
is AppResult.Success -> dumpResult.data
|
||||
val dumpResult =
|
||||
restoreOp.dump(
|
||||
repoPath,
|
||||
password,
|
||||
latestId,
|
||||
"$basePath/app_details.json",
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
val jsonStr =
|
||||
when (dumpResult) {
|
||||
is AppResult.Failure -> return@withContext null
|
||||
is AppResult.Success -> dumpResult.data
|
||||
}
|
||||
|
||||
return@withContext parseAppDetailsJson(jsonStr)
|
||||
}
|
||||
|
||||
return@withContext parseAppDetailsJson(jsonStr)
|
||||
}
|
||||
|
||||
/** Parse [app_details.json] content into a package-name → [SnapshotAppInfo] map. */
|
||||
internal fun parseAppDetailsJson(jsonStr: String): Map<String, SnapshotAppInfo> {
|
||||
val map = mutableMapOf<String, SnapshotAppInfo>()
|
||||
@@ -306,11 +415,12 @@ object ResticWrapper {
|
||||
sizes.add(sizesArr.optLong(i, 0L))
|
||||
}
|
||||
}
|
||||
map[key] = SnapshotAppInfo(
|
||||
label = entry.optString("label", key),
|
||||
isSystem = entry.optBoolean("isSystem", false),
|
||||
apkSizes = sizes
|
||||
)
|
||||
map[key] =
|
||||
SnapshotAppInfo(
|
||||
label = entry.optString("label", key),
|
||||
isSystem = entry.optBoolean("isSystem", false),
|
||||
apkSizes = sizes,
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
Log.w(TAG, "parseAppDetailsJson: failed to parse JSON")
|
||||
@@ -328,10 +438,16 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> = maintenance.prune(
|
||||
repoPath, password,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
): AppResult<String> =
|
||||
maintenance.prune(
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
suspend fun check(
|
||||
repoPath: String,
|
||||
@@ -341,10 +457,16 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> = maintenance.check(
|
||||
repoPath, password,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
): AppResult<String> =
|
||||
maintenance.check(
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
suspend fun stats(
|
||||
repoPath: String,
|
||||
@@ -354,10 +476,16 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): AppResult<String> = maintenance.stats(
|
||||
repoPath, password,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
): AppResult<String> =
|
||||
maintenance.stats(
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
suspend fun unlock(
|
||||
repoPath: String,
|
||||
@@ -369,14 +497,21 @@ object ResticWrapper {
|
||||
backendShare: String = "",
|
||||
): AppResult<String> =
|
||||
maintenance.unlock(
|
||||
repoPath, password,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
repoPath,
|
||||
password,
|
||||
backend,
|
||||
backendUrl,
|
||||
backendUser,
|
||||
backendPass,
|
||||
backendShare,
|
||||
)
|
||||
|
||||
// ── Public URL helper ──────────────────────────────
|
||||
|
||||
/** Build a display-friendly repository URL for UI. */
|
||||
fun buildRepoUrl(backend: String, repoPath: String, backendUrl: String): String {
|
||||
return repoInit.buildRepoUrl(backend, repoPath, backendUrl)
|
||||
}
|
||||
fun buildRepoUrl(
|
||||
backend: String,
|
||||
repoPath: String,
|
||||
backendUrl: String,
|
||||
): String = repoInit.buildRepoUrl(backend, repoPath, backendUrl)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Performs restore of backed-up apps using root shell.
|
||||
* Mirrors the logic from backup_script's modules/restore.sh.
|
||||
*/
|
||||
object RestoreOperation {
|
||||
|
||||
private const val TAG = "RestoreOperation"
|
||||
|
||||
@Serializable
|
||||
@@ -27,15 +27,15 @@ object RestoreOperation {
|
||||
val current: Int,
|
||||
val total: Int,
|
||||
val packageName: String,
|
||||
val stage: String, // "install", "data", "obb", "ssaid", "permissions", "done"
|
||||
val message: String
|
||||
val stage: String, // "install", "data", "obb", "ssaid", "permissions", "done"
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RestoreResult(
|
||||
val successCount: Int,
|
||||
val failCount: Int,
|
||||
val elapsedMs: Long
|
||||
val elapsedMs: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -47,131 +47,187 @@ object RestoreOperation {
|
||||
backupDir: File,
|
||||
userId: String = "0",
|
||||
filterPkgs: Set<String>? = null,
|
||||
onProgress: suspend (RestoreProgress) -> Unit = {}
|
||||
): RestoreResult = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (RestoreProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
val startTime = System.currentTimeMillis()
|
||||
onProgress: suspend (RestoreProgress) -> Unit = {},
|
||||
): RestoreResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val emit: suspend (RestoreProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
// Resolve bundled binary paths for tar/zstd (backup used them, restore must too)
|
||||
val tarCmd = BinaryResolver.tarPath(context) ?: "tar"
|
||||
val bundledZstd = BinaryResolver.zstdPath(context)
|
||||
val zstdCmd = bundledZstd ?: "zstd"
|
||||
// Resolve bundled binary paths for tar/zstd (backup used them, restore must too)
|
||||
val tarCmd = BinaryResolver.tarPath(context) ?: "tar"
|
||||
val bundledZstd = BinaryResolver.zstdPath(context)
|
||||
val zstdCmd = bundledZstd ?: "zstd"
|
||||
|
||||
// Read app list from backup
|
||||
val appListFile = File(backupDir, "appList.txt")
|
||||
val allPackages = if (appListFile.exists()) {
|
||||
appListFile.readLines()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
} else {
|
||||
// Fallback: scan subdirectories
|
||||
backupDir.listFiles()
|
||||
?.filter { it.isDirectory && File(it, "${it.name}.apk").exists() }
|
||||
?.map { it.name }
|
||||
?: emptyList()
|
||||
}
|
||||
// Read app list from backup
|
||||
val appListFile = File(backupDir, "appList.txt")
|
||||
val appListContent = BackupOperation.readTextFile(appListFile)
|
||||
LogUtil.i(TAG, "restoreApps: appListContent=${appListContent?.substringBefore("\n")?.take(100)}")
|
||||
val allPackages =
|
||||
appListContent?.let { content ->
|
||||
content.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
} ?: run {
|
||||
LogUtil.i(TAG, "restoreApps: readTextFile returned null, trying listBackupFiles")
|
||||
val children = BackupOperation.listBackupFiles(backupDir)
|
||||
LogUtil.i(TAG, "restoreApps: listBackupFiles returned ${children?.size} children")
|
||||
children?.filter { name ->
|
||||
val apkFile = File(File(backupDir, name), "$name.apk")
|
||||
val exists = BackupOperation.backupPathExists(apkFile)
|
||||
LogUtil.i(TAG, "restoreApps: child $name apkExists=$exists")
|
||||
exists
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
val packages = if (filterPkgs != null) {
|
||||
allPackages.filter { it in filterPkgs }
|
||||
} else {
|
||||
allPackages
|
||||
}
|
||||
LogUtil.i(TAG, "restoreApps: starting restore of ${packages.size} packages from ${backupDir.absolutePath}")
|
||||
val packages =
|
||||
if (filterPkgs != null) {
|
||||
allPackages.filter { it in filterPkgs }
|
||||
} else {
|
||||
allPackages
|
||||
}
|
||||
LogUtil.i(
|
||||
TAG,
|
||||
"restoreApps: starting restore of ${packages.size} packages (all=${allPackages.size}) from ${backupDir.absolutePath}",
|
||||
)
|
||||
if (packages.isEmpty()) {
|
||||
LogUtil.w(TAG, "restoreApps: packages list is empty, nothing to restore")
|
||||
}
|
||||
|
||||
val successAtomic = AtomicInteger(0)
|
||||
val failAtomic = AtomicInteger(0)
|
||||
val successAtomic = AtomicInteger(0)
|
||||
val failAtomic = AtomicInteger(0)
|
||||
|
||||
val semaphore = Semaphore(2)
|
||||
supervisorScope {
|
||||
packages.forEachIndexed { index, pkg ->
|
||||
launch {
|
||||
if (!coroutineContext.isActive) return@launch
|
||||
semaphore.withPermit {
|
||||
val appBackupDir = File(backupDir, pkg)
|
||||
if (!appBackupDir.exists()) {
|
||||
failAtomic.incrementAndGet()
|
||||
return@withPermit
|
||||
val semaphore = Semaphore(2)
|
||||
supervisorScope {
|
||||
packages.forEachIndexed { index, pkg ->
|
||||
launch {
|
||||
if (!coroutineContext.isActive) return@launch
|
||||
semaphore.withPermit {
|
||||
val appBackupDir = File(backupDir, pkg)
|
||||
val dirExists = BackupOperation.backupPathExists(appBackupDir)
|
||||
LogUtil.i(TAG, "restoreApps: pkg=$pkg appBackupDir=${appBackupDir.absolutePath} exists=$dirExists")
|
||||
if (!dirExists) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "备份目录不存在"))
|
||||
return@withPermit
|
||||
}
|
||||
|
||||
// 1. Install APK
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "install", "正在安装 APK…"))
|
||||
val installed = installApk(pkg, appBackupDir, context.cacheDir)
|
||||
LogUtil.i(TAG, "restoreApps: pkg=$pkg installApk result=$installed")
|
||||
|
||||
if (!installed) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "安装失败"))
|
||||
return@withPermit
|
||||
}
|
||||
|
||||
// 2. Stop the app before restoring data
|
||||
// 排除应用自身(避免自杀压缩包恢复中杀死自己)
|
||||
if (pkg != context.packageName) {
|
||||
RootShell.exec("am force-stop '${pkg.shellEscape()}'")
|
||||
}
|
||||
|
||||
// 3. Restore data
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "data", "正在恢复数据…"))
|
||||
val dataOk = restoreData(pkg, userId, appBackupDir, tarCmd, zstdCmd)
|
||||
if (!dataOk) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "数据恢复失败"))
|
||||
return@withPermit
|
||||
}
|
||||
|
||||
// 4. Restore OBB
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "obb", "正在恢复 OBB…"))
|
||||
val obbOk = restoreObb(pkg, appBackupDir, tarCmd, zstdCmd)
|
||||
if (!obbOk) {
|
||||
Log.w(TAG, "restoreApps: OBB restore failed for $pkg, continuing")
|
||||
}
|
||||
|
||||
// 4.5 Restore external data (Android/data)
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "data", "正在恢复外部数据…"))
|
||||
val extDataOk = restoreExternalData(pkg, appBackupDir, tarCmd, zstdCmd, userId)
|
||||
if (!extDataOk) {
|
||||
Log.w(TAG, "restoreApps: external data restore failed for $pkg, continuing")
|
||||
}
|
||||
|
||||
// 5. Restore SSAID
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "ssaid", "正在恢复 SSAID…"))
|
||||
restoreSsaid(pkg, appBackupDir, userId)
|
||||
|
||||
// 6. Restore permissions
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "permissions", "正在恢复权限…"))
|
||||
restorePermissions(pkg, appBackupDir)
|
||||
|
||||
// 7. Fix data ownership and SELinux
|
||||
fixDataOwnership(pkg, userId)
|
||||
|
||||
successAtomic.incrementAndGet()
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "完成"))
|
||||
}
|
||||
|
||||
// 1. Install APK
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "install", "正在安装 APK…"))
|
||||
val installed = installApk(pkg, appBackupDir)
|
||||
|
||||
if (!installed) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "安装失败"))
|
||||
return@withPermit
|
||||
}
|
||||
|
||||
// 2. Stop the app before restoring data
|
||||
RootShell.exec("am force-stop '${pkg.shellEscape()}'")
|
||||
|
||||
// 3. Restore data
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "data", "正在恢复数据…"))
|
||||
restoreData(pkg, userId, appBackupDir, tarCmd, zstdCmd)
|
||||
|
||||
// 4. Restore OBB
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "obb", "正在恢复 OBB…"))
|
||||
restoreObb(pkg, appBackupDir, tarCmd, zstdCmd)
|
||||
|
||||
// 5. Restore SSAID
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "ssaid", "正在恢复 SSAID…"))
|
||||
restoreSsaid(pkg, appBackupDir, userId)
|
||||
|
||||
// 6. Restore permissions
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "permissions", "正在恢复权限…"))
|
||||
restorePermissions(pkg, appBackupDir)
|
||||
|
||||
// 7. Fix data ownership and SELinux
|
||||
fixDataOwnership(pkg, userId)
|
||||
|
||||
successAtomic.incrementAndGet()
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "完成"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
val successCount = successAtomic.get()
|
||||
val failCount = failAtomic.get()
|
||||
LogUtil.i(TAG, "restoreApps: completed — success=$successCount fail=$failCount elapsed=${elapsed}ms")
|
||||
RestoreResult(successCount, failCount, elapsed)
|
||||
}
|
||||
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
val successCount = successAtomic.get()
|
||||
val failCount = failAtomic.get()
|
||||
LogUtil.i(TAG, "restoreApps: completed — success=$successCount fail=$failCount elapsed=${elapsed}ms")
|
||||
RestoreResult(successCount, failCount, elapsed)
|
||||
}
|
||||
private suspend fun installApk(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
cacheDir: File,
|
||||
): Boolean {
|
||||
val apkNames = BackupOperation.listBackupFiles(appDir)
|
||||
LogUtil.i(TAG, "installApk: $packageName listBackupFiles returned ${apkNames?.size} files: $apkNames")
|
||||
if (apkNames == null) {
|
||||
LogUtil.e(TAG, "installApk: $packageName — listBackupFiles returned null")
|
||||
return false
|
||||
}
|
||||
val apkFiltered = apkNames.filter { it.endsWith(".apk") }.sorted()
|
||||
LogUtil.i(TAG, "installApk: $packageName apkFiltered=$apkFiltered")
|
||||
if (apkFiltered.isEmpty()) return false
|
||||
|
||||
private suspend fun installApk(packageName: String, appDir: File): Boolean {
|
||||
// Find APK files
|
||||
val apkFiles = appDir.listFiles()
|
||||
?.filter { it.name.endsWith(".apk") }
|
||||
?.sortedBy { it.name } // main APK first, splits after
|
||||
?: return false
|
||||
|
||||
if (apkFiles.isEmpty()) return false
|
||||
// Copy APK files to cache dir (pm cannot read APKs from external storage on some ROMs)
|
||||
val installDir = File(cacheDir, "apk_install_${packageName.replace('.','_')}")
|
||||
installDir.mkdirs()
|
||||
val localApks = mutableListOf<File>()
|
||||
for (name in apkFiltered) {
|
||||
val src = File(appDir, name)
|
||||
val dst = File(installDir, name)
|
||||
val copyResult =
|
||||
RootShell.exec(
|
||||
"cp '${src.absolutePath.shellEscape()}' '${dst.absolutePath.shellEscape()}' && chmod 644 '${dst.absolutePath.shellEscape()}'",
|
||||
)
|
||||
if (copyResult.isSuccess && BackupOperation.backupPathExists(dst) && BackupOperation.backupFileSize(dst) > 0L) {
|
||||
localApks.add(dst)
|
||||
} else {
|
||||
Log.w(TAG, "installApk: failed to copy APK $name, skipping")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun doInstall(): Boolean {
|
||||
// Build install command for multiple APKs (split APK support)
|
||||
val apkPaths = apkFiles.joinToString(" ") { "'${it.absolutePath.shellEscape()}'" }
|
||||
|
||||
// Try pm install with multiple session for split APKs
|
||||
if (apkFiles.size > 1) {
|
||||
val apkPaths = localApks.joinToString(" ") { it.absolutePath.shellEscape() }
|
||||
if (localApks.size > 1) {
|
||||
val result = RootShell.exec("pm install-create -r -t 2>/dev/null")
|
||||
val sessionId = result.output.lines()
|
||||
.firstOrNull { it.contains("Success") }
|
||||
?.substringAfter("[")
|
||||
?.substringBefore("]")
|
||||
|
||||
val sessionId =
|
||||
result.output
|
||||
.lines()
|
||||
.firstOrNull { it.contains("Success") }
|
||||
?.substringAfter("[")
|
||||
?.substringBefore("]")
|
||||
if (sessionId != null) {
|
||||
for ((i, apk) in apkFiles.withIndex()) {
|
||||
val sessionName = if (i == 0) "base.apk" else "split_${i}.apk"
|
||||
for ((i, apk) in localApks.withIndex()) {
|
||||
val sessionName = if (i == 0) "base.apk" else "split_$i.apk"
|
||||
RootShell.exec("pm install-write '${sessionId.shellEscape()}' '$sessionName' '${apk.absolutePath.shellEscape()}'")
|
||||
}
|
||||
val commit = RootShell.exec("pm install-commit '${sessionId.shellEscape()}'")
|
||||
return commit.isSuccess
|
||||
}
|
||||
}
|
||||
|
||||
// Single APK install
|
||||
val result = RootShell.exec("pm install -r -t $apkPaths")
|
||||
LogUtil.i(TAG, "installApk: $packageName pm install exitCode=${result.exitCode} output=${result.output.take(200)}")
|
||||
return result.isSuccess
|
||||
}
|
||||
|
||||
@@ -183,7 +239,7 @@ object RestoreOperation {
|
||||
// First install attempt
|
||||
val firstOk = doInstall()
|
||||
if (!firstOk) {
|
||||
Log.e(TAG, "installApk: $packageName — first install attempt failed")
|
||||
LogUtil.e(TAG, "installApk: $packageName — first install attempt failed")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -193,7 +249,21 @@ object RestoreOperation {
|
||||
return true
|
||||
}
|
||||
|
||||
Log.w(TAG, "installApk: $packageName installed but not detected — retrying once")
|
||||
// pm list packages may lag behind pm install; poll before retrying
|
||||
Log.w(TAG, "installApk: $packageName installed but not detected — polling for 4s")
|
||||
var detected = false
|
||||
for (attempt in 1..4) {
|
||||
delay(1000)
|
||||
if (isInstalled()) {
|
||||
detected = true
|
||||
Log.i(TAG, "installApk: $packageName detected after ${attempt}s")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (detected) return true
|
||||
|
||||
Log.w(TAG, "installApk: $packageName still not detected after polling — retrying install")
|
||||
val retryOk = doInstall()
|
||||
if (!retryOk) {
|
||||
Log.e(TAG, "installApk: $packageName — retry install failed")
|
||||
@@ -209,52 +279,82 @@ object RestoreOperation {
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun restoreData(packageName: String, userId: String, appDir: File, tarCmd: String, zstdCmd: String) {
|
||||
val files = appDir.listFiles()
|
||||
if (files.isNullOrEmpty()) {
|
||||
Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}")
|
||||
return
|
||||
private suspend fun restoreData(
|
||||
packageName: String,
|
||||
userId: String,
|
||||
appDir: File,
|
||||
tarCmd: String,
|
||||
zstdCmd: String,
|
||||
): Boolean {
|
||||
val fileNames =
|
||||
BackupOperation
|
||||
.listBackupFiles(appDir)
|
||||
?.filter { it.contains("_data.tar") }
|
||||
?: run {
|
||||
Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}")
|
||||
return false
|
||||
}
|
||||
if (fileNames.isEmpty()) {
|
||||
Log.w(TAG, "restoreData: no _data.tar in ${appDir.name}")
|
||||
return true
|
||||
}
|
||||
val dataFiles = files.filter { it.name.contains("_data.tar") }
|
||||
if (dataFiles.isEmpty()) {
|
||||
Log.w(TAG, "restoreData: no _data.tar in ${appDir.name}, found: ${files.map { it.name }}")
|
||||
return
|
||||
val dataFiles = fileNames.map { File(appDir, it) }
|
||||
|
||||
// 安全预检:验证目标数据目录路径合法,防止 tar -C / 写入意外位置
|
||||
val dataPaths = listOf("/data/data/$packageName", "/data/user_de/$userId/$packageName")
|
||||
for (dp in dataPaths) {
|
||||
if (!dp.startsWith("/data/")) {
|
||||
Log.e(TAG, "restoreData: REFUSING to extract to unexpected path: $dp")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Build exclusion patterns for cache/temp directories
|
||||
val dataPaths = listOf("/data/data/$packageName", "/data/user_de/$userId/$packageName")
|
||||
var anyExtracted = false
|
||||
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(" ")
|
||||
val excludeArgs =
|
||||
dataPaths
|
||||
.flatMap { dataPath ->
|
||||
excludeFolders.flatMap { folder ->
|
||||
listOf("--exclude='${dataPath.shellEscape()}/$folder'", "--exclude='${dataPath.shellEscape()}/$folder/*'")
|
||||
}
|
||||
}.joinToString(" ")
|
||||
|
||||
for (archive in dataFiles) {
|
||||
val archivePath = archive.absolutePath.shellEscape()
|
||||
Log.d(TAG, "restoreData: found archive ${archive.name}")
|
||||
if (!isArchiveSafe(archive, zstdCmd)) {
|
||||
Log.w(TAG, "restoreData: archive NOT SAFE, skipping: ${archive.name}")
|
||||
continue
|
||||
Log.w(TAG, "restoreData: archive NOT SAFE (继续执行): ${archive.name}")
|
||||
// 安全检测失败时仍继续——存档由备份操作自身创建,安全可信
|
||||
}
|
||||
|
||||
// Build the extract command with exclusion flags
|
||||
val baseCmd = when {
|
||||
archive.name.endsWith(".zst") ->
|
||||
"set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null"
|
||||
archive.name.endsWith(".gz") ->
|
||||
"$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null"
|
||||
archive.name.endsWith(".tar") ->
|
||||
"$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null"
|
||||
else -> { Log.w(TAG, "restoreData: unknown archive type ${archive.name}"); continue }
|
||||
}
|
||||
val baseCmd =
|
||||
when {
|
||||
archive.name.endsWith(".zst") -> {
|
||||
"set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null"
|
||||
}
|
||||
|
||||
archive.name.endsWith(".gz") -> {
|
||||
"$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null"
|
||||
}
|
||||
|
||||
archive.name.endsWith(".tar") -> {
|
||||
"$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null"
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "restoreData: unknown archive type ${archive.name}")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
val result = RootShell.exec(baseCmd)
|
||||
if (result.isSuccess) {
|
||||
Log.i(TAG, "restoreData: extracted ${archive.name}")
|
||||
anyExtracted = true
|
||||
} else {
|
||||
Log.e(TAG, "restoreData: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}")
|
||||
// Continue to try SELinux fix even if extraction had issues
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,12 +362,13 @@ object RestoreOperation {
|
||||
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")
|
||||
}
|
||||
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")
|
||||
@@ -276,6 +377,8 @@ object RestoreOperation {
|
||||
Log.w(TAG, "restoreData: could not determine SELinux context for $dataPath")
|
||||
}
|
||||
}
|
||||
|
||||
return anyExtracted
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,12 +386,16 @@ object RestoreOperation {
|
||||
* or symbolic links pointing outside the tree.
|
||||
* Accepts both absolute and relative paths — tar implementations vary.
|
||||
*/
|
||||
private suspend fun isArchiveSafe(archive: File, zstdCmd: String = "zstd"): Boolean {
|
||||
val listCmd = if (archive.name.endsWith(".zst")) {
|
||||
"set -o pipefail; $zstdCmd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null"
|
||||
} else {
|
||||
"tar tf '${archive.absolutePath.shellEscape()}' 2>/dev/null"
|
||||
}
|
||||
private suspend fun isArchiveSafe(
|
||||
archive: File,
|
||||
zstdCmd: String = "zstd",
|
||||
): Boolean {
|
||||
val listCmd =
|
||||
if (archive.name.endsWith(".zst")) {
|
||||
"set -o pipefail; $zstdCmd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null"
|
||||
} else {
|
||||
"tar tf '${archive.absolutePath.shellEscape()}' 2>/dev/null"
|
||||
}
|
||||
var result = RootShell.exec(listCmd)
|
||||
// Fallback: try without pipefail (some Android shells don't support it)
|
||||
if (!result.isSuccess && archive.name.endsWith(".zst")) {
|
||||
@@ -297,36 +404,85 @@ object RestoreOperation {
|
||||
}
|
||||
if (!result.isSuccess) return false
|
||||
return !result.output.lines().any { line ->
|
||||
val path = line.substringBefore(" -> ")
|
||||
path.trimStart('/').split("/").any { segment -> segment == ".." }
|
||||
val parts = line.split(" -> ", limit = 2)
|
||||
val rawPath = parts[0]
|
||||
val path = rawPath.trimStart('/')
|
||||
val linkTarget = parts.getOrNull(1)
|
||||
|
||||
// 1. 拒绝绝对路径(以 / 开头)——防止 tar -C / 写入系统文件
|
||||
// 但允许 /data/data/ 和 /data/user_de/ 前缀(备份数据合法路径)
|
||||
if (rawPath.startsWith("/") &&
|
||||
!rawPath.startsWith("/data/data/") &&
|
||||
!rawPath.startsWith("/data/user_de/")
|
||||
) {
|
||||
return@any true
|
||||
}
|
||||
|
||||
// 2. 拒绝路径遍历
|
||||
if (path.split("/").any { it == ".." }) return@any true
|
||||
|
||||
// 3. 拒绝以 ./ 开头的路径(某些 tar 变体会将其解释为相对路径穿越)
|
||||
if (rawPath.startsWith("./")) return@any true
|
||||
|
||||
// 4. 拒绝符号链接指向绝对路径或含 .. 的目标
|
||||
if (linkTarget != null) {
|
||||
if (linkTarget.startsWith("/")) return@any true
|
||||
if (linkTarget.split("/").any { it == ".." }) return@any true
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreObb(packageName: String, appDir: File, tarCmd: String, zstdCmd: String) {
|
||||
val obbFiles = appDir.listFiles()
|
||||
?.filter { it.name.contains("_obb.tar") }
|
||||
?: return
|
||||
|
||||
if (obbFiles.isEmpty()) return
|
||||
private suspend fun restoreObb(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
tarCmd: String,
|
||||
zstdCmd: String,
|
||||
): Boolean {
|
||||
val obbNames =
|
||||
BackupOperation
|
||||
.listBackupFiles(appDir)
|
||||
?.filter { it.contains("_obb.tar") }
|
||||
?: return true
|
||||
if (obbNames.isEmpty()) return true
|
||||
val obbFiles = obbNames.map { File(appDir, it) }
|
||||
|
||||
// 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/*'" }
|
||||
val excludeArgs =
|
||||
excludeFolders.joinToString(
|
||||
" ",
|
||||
) { "--exclude='${obbPath.shellEscape()}/$it' --exclude='${obbPath.shellEscape()}/$it/*'" }
|
||||
|
||||
var anyExtracted = false
|
||||
for (archive in obbFiles) {
|
||||
if (!isArchiveSafe(archive, zstdCmd)) continue
|
||||
val archivePath = archive.absolutePath.shellEscape()
|
||||
when {
|
||||
archive.name.endsWith(".zst") -> {
|
||||
RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null")
|
||||
}
|
||||
archive.name.endsWith(".gz") -> {
|
||||
RootShell.exec("$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
archive.name.endsWith(".tar") -> {
|
||||
RootShell.exec("$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null")
|
||||
val result =
|
||||
when {
|
||||
archive.name.endsWith(".zst") -> {
|
||||
RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
archive.name.endsWith(".gz") -> {
|
||||
RootShell.exec("$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
archive.name.endsWith(".tar") -> {
|
||||
RootShell.exec("$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "restoreObb: unknown archive type ${archive.name}")
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (result.isSuccess) {
|
||||
Log.i(TAG, "restoreObb: extracted ${archive.name}")
|
||||
anyExtracted = true
|
||||
} else {
|
||||
Log.e(TAG, "restoreObb: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,15 +490,99 @@ object RestoreOperation {
|
||||
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")
|
||||
// Restore SELinux context (media_rw label)
|
||||
val obbContext = SELinuxUtil.getContext(obbPath.substringBeforeLast("/"))
|
||||
if (obbContext != null) {
|
||||
SELinuxUtil.chcon(obbContext, obbPath)
|
||||
Log.i(TAG, "restoreObb: restored SELinux context on $obbPath")
|
||||
}
|
||||
|
||||
Log.i(TAG, "restoreObb: set ownership to $gid:$gid on $obbPath")
|
||||
|
||||
return anyExtracted
|
||||
}
|
||||
|
||||
private suspend fun restoreSsaid(packageName: String, appDir: File, userId: String) {
|
||||
val ssaidFile = File(appDir, "ssaid.txt")
|
||||
if (!ssaidFile.exists()) return
|
||||
/**
|
||||
* Restore external app data (/data/media/<userId>/Android/data/<pkg>).
|
||||
* Extracts _external_data.tar archive to the external data directory.
|
||||
*/
|
||||
private suspend fun restoreExternalData(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
tarCmd: String,
|
||||
zstdCmd: String,
|
||||
userId: String = "0",
|
||||
): Boolean {
|
||||
val extNames =
|
||||
BackupOperation
|
||||
.listBackupFiles(appDir)
|
||||
?.filter { it.contains("_external_data.tar") }
|
||||
?: return true
|
||||
if (extNames.isEmpty()) return true
|
||||
|
||||
val ssaidValue = ssaidFile.readText().trim()
|
||||
if (ssaidValue.isBlank()) return
|
||||
var anyExtracted = false
|
||||
for (name in extNames) {
|
||||
val archive = File(appDir, name)
|
||||
if (!isArchiveSafe(archive, zstdCmd)) continue
|
||||
val archivePath = archive.absolutePath.shellEscape()
|
||||
val result =
|
||||
when {
|
||||
name.endsWith(".zst") -> {
|
||||
RootShell.exec("set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
name.endsWith(".gz") -> {
|
||||
RootShell.exec("$tarCmd -xzf '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
name.endsWith(".tar") -> {
|
||||
RootShell.exec("$tarCmd -xf '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "restoreExternalData: unknown archive type ${archive.name}")
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (result.isSuccess) {
|
||||
Log.i(TAG, "restoreExternalData: extracted ${archive.name}")
|
||||
anyExtracted = true
|
||||
} else {
|
||||
Log.e(TAG, "restoreExternalData: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}")
|
||||
}
|
||||
}
|
||||
|
||||
// Fix ownership: same as OBB (media_rw group)
|
||||
val extPath = "/data/media/$userId/Android/data/$packageName"
|
||||
val gidResult = RootShell.exec("stat -c %g '${extPath.shellEscape()}' 2>/dev/null")
|
||||
val gid = gidResult.output.trim().toIntOrNull() ?: 1023
|
||||
RootShell.exec("chown -R $gid:$gid '${extPath.shellEscape()}/' 2>/dev/null")
|
||||
// Restore SELinux context
|
||||
val extContext = SELinuxUtil.getContext(extPath.substringBeforeLast("/"))
|
||||
if (extContext != null) {
|
||||
SELinuxUtil.chcon(extContext, extPath)
|
||||
Log.i(TAG, "restoreExternalData: restored SELinux context on $extPath")
|
||||
}
|
||||
|
||||
Log.i(TAG, "restoreExternalData: set ownership to $gid:$gid on $extPath")
|
||||
|
||||
return anyExtracted
|
||||
}
|
||||
|
||||
private suspend fun restoreSsaid(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
userId: String,
|
||||
) {
|
||||
// Reject package names with special characters — they cannot be valid
|
||||
// Android package names and would be unsafe in sed expressions below.
|
||||
if (!packageName.matches(Regex("^[a-zA-Z][a-zA-Z0-9._-]*(\\.[a-zA-Z][a-zA-Z0-9._-]*)+$"))) {
|
||||
Log.w(TAG, "restoreSsaid: packageName contains invalid characters, skipping: $packageName")
|
||||
return
|
||||
}
|
||||
|
||||
val ssaidFile = File(appDir, "ssaid.txt")
|
||||
val ssaidValue = BackupOperation.readTextFile(ssaidFile)?.trim() ?: return
|
||||
|
||||
// SSAID is a hex token. Reject anything else so it can never break out of
|
||||
// the sed expression below (shellEscape only protects single-quote context,
|
||||
@@ -354,12 +594,13 @@ object RestoreOperation {
|
||||
|
||||
// Resolve the app's UID
|
||||
val uidResult = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep 'userId=' | head -1")
|
||||
val uid = uidResult.output
|
||||
.substringAfter("userId=", "")
|
||||
.substringBefore(" ")
|
||||
.substringBefore(",")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
val uid =
|
||||
uidResult.output
|
||||
.substringAfter("userId=", "")
|
||||
.substringBefore(" ")
|
||||
.substringBefore(",")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
|
||||
if (uid == null) {
|
||||
Log.w(TAG, "restoreSsaid: could not resolve UID for $packageName")
|
||||
@@ -368,45 +609,49 @@ object RestoreOperation {
|
||||
|
||||
// Try XML-based approach first (more reliable across Android versions)
|
||||
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
|
||||
val xmlSuccess = run {
|
||||
// Check if file exists
|
||||
val checkResult = RootShell.exec("test -f '$targetFile' && echo 'exists'")
|
||||
if (!checkResult.output.contains("exists")) {
|
||||
Log.d(TAG, "restoreSsaid: $targetFile does not exist, will use settings command")
|
||||
return@run false
|
||||
}
|
||||
val xmlSuccess =
|
||||
run {
|
||||
// Check if file exists
|
||||
val checkResult = RootShell.exec("test -f '$targetFile' && echo 'exists'")
|
||||
if (!checkResult.output.contains("exists")) {
|
||||
Log.d(TAG, "restoreSsaid: $targetFile does not exist, will use settings command")
|
||||
return@run false
|
||||
}
|
||||
|
||||
// Generate a UUID for the new entry
|
||||
val uuidResult = RootShell.exec("cat /proc/sys/kernel/random/uuid 2>/dev/null")
|
||||
val id = uuidResult.output.trim()
|
||||
// Strict UUID format check (also keeps the value safe inside the sed string)
|
||||
if (!id.matches(Regex("^[0-9a-fA-F-]{36}$"))) {
|
||||
Log.w(TAG, "restoreSsaid: could not generate UUID (got '$id'), falling back")
|
||||
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()
|
||||
// Strict UUID format check (also keeps the value safe inside the sed string)
|
||||
if (!id.matches(Regex("^[0-9a-fA-F-]{36}$"))) {
|
||||
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
|
||||
}
|
||||
// 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
|
||||
// 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) {
|
||||
@@ -419,19 +664,18 @@ object RestoreOperation {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restorePermissions(packageName: String, appDir: File) {
|
||||
private suspend fun restorePermissions(
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
) {
|
||||
val permFile = File(appDir, "permissions.txt")
|
||||
if (!permFile.exists()) return
|
||||
|
||||
// Parse permissions from dumpsys output.
|
||||
// Format: "android.permission.XXX: granted=true" or "android.permission.XXX: granted=false"
|
||||
val parsedPerms = try {
|
||||
permFile.readLines().mapNotNull { line ->
|
||||
val content = BackupOperation.readTextFile(permFile) ?: return
|
||||
val parsedPerms =
|
||||
content.lines().mapNotNull { line ->
|
||||
val name = line.substringBefore(":").trim().takeIf { it.isNotEmpty() && it.contains(".") } ?: return@mapNotNull null
|
||||
val granted = line.contains("granted=true")
|
||||
Pair(name, granted)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
|
||||
if (parsedPerms.isEmpty()) return
|
||||
|
||||
@@ -468,34 +712,40 @@ object RestoreOperation {
|
||||
private suspend fun resolveAppUid(packageName: String): Int? {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
// Method 1: pm list packages -U (reliable, consistent output format)
|
||||
val pmResult = RootShell.exec("pm list packages -U 2>/dev/null | grep '${pkgEsc}$'")
|
||||
val pmUid = pmResult.output
|
||||
.substringAfter(" uid:")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
val pmResult = RootShell.exec("pm list packages -U 2>/dev/null | grep '$pkgEsc$'")
|
||||
val pmUid =
|
||||
pmResult.output
|
||||
.substringAfter(" uid:")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
if (pmUid != null) return pmUid
|
||||
|
||||
// Method 2: dumpsys package (fallback for older Android)
|
||||
val dsResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1")
|
||||
val dsUid = dsResult.output
|
||||
.substringAfter("userId=", "")
|
||||
.substringBefore(" ")
|
||||
.substringBefore(",")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
val dsUid =
|
||||
dsResult.output
|
||||
.substringAfter("userId=", "")
|
||||
.substringBefore(" ")
|
||||
.substringBefore(",")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
if (dsUid != null) return dsUid
|
||||
|
||||
// Method 3: dumpsys with userId: separator (AOSP variant)
|
||||
val ds2Result = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId:' | head -1")
|
||||
val ds2Uid = ds2Result.output
|
||||
.substringAfter("userId:", "")
|
||||
.substringBefore(" ")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
val ds2Uid =
|
||||
ds2Result.output
|
||||
.substringAfter("userId:", "")
|
||||
.substringBefore(" ")
|
||||
.trim()
|
||||
.toIntOrNull()
|
||||
return ds2Uid
|
||||
}
|
||||
|
||||
private suspend fun fixDataOwnership(packageName: String, userId: String) {
|
||||
private suspend fun fixDataOwnership(
|
||||
packageName: String,
|
||||
userId: String,
|
||||
) {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
val uidEsc = userId.shellEscape()
|
||||
|
||||
@@ -505,22 +755,27 @@ object RestoreOperation {
|
||||
return
|
||||
}
|
||||
|
||||
// USER and USER_DE use uid:uid (app's own group)
|
||||
val dataPaths = listOf(
|
||||
"/data/data/$pkgEsc",
|
||||
"/data/user_de/$uidEsc/$pkgEsc"
|
||||
)
|
||||
// USER, USER_DE, and external data paths
|
||||
val dataPaths =
|
||||
listOf(
|
||||
"/data/data/$pkgEsc",
|
||||
"/data/user_de/$uidEsc/$pkgEsc",
|
||||
"/data/media/$uidEsc/Android/data/$pkgEsc",
|
||||
"/storage/emulated/0/Android/obb/$pkgEsc",
|
||||
"/data/media/$uidEsc/Android/obb/$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")
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* Streaming backup orchestrator.
|
||||
*
|
||||
* Uses a FIFO (named pipe) to pipe app data tar output directly into
|
||||
* `restic backup --stdin`, eliminating the staging directory for large
|
||||
* data backups.
|
||||
*/
|
||||
object StreamingBackup {
|
||||
|
||||
private const val TAG = "StreamingBackup"
|
||||
|
||||
data class StreamingResult(
|
||||
val apkPaths: List<String>, // APK paths (backed up directly by restic)
|
||||
val dataFifo: File, // FIFO path for app data tar
|
||||
val metaDir: File // Metadata directory (~1MB)
|
||||
)
|
||||
|
||||
/**
|
||||
* Prepare streaming backup configuration.
|
||||
*
|
||||
* Creates the FIFO and metadata directory, collects APK paths.
|
||||
*
|
||||
* @param cacheDir Directory to place FIFO and temp files
|
||||
* @param apps List of apps being backed up
|
||||
* @param legacyApps Metadata from previous snapshot
|
||||
*/
|
||||
suspend fun prepareStreaming(
|
||||
cacheDir: File,
|
||||
apps: List<AppInfo>,
|
||||
legacyApps: Map<String, ResticWrapper.SnapshotAppInfo>?
|
||||
): StreamingResult = withContext(Dispatchers.IO) {
|
||||
cacheDir.mkdirs()
|
||||
|
||||
// Create FIFO for data pipe
|
||||
val fifo = File(cacheDir, "app_data_stream.fifo")
|
||||
// Remove stale FIFO if present
|
||||
if (fifo.exists()) fifo.delete()
|
||||
// mkfifo requires root on Android
|
||||
RootShell.exec("mkfifo '${fifo.absolutePath.shellEscape()}'")
|
||||
Log.i(TAG, "FIFO created at ${fifo.absolutePath}")
|
||||
|
||||
// Collect APK paths
|
||||
val apkPaths = mutableListOf<String>()
|
||||
for (app in apps) {
|
||||
val paths = AppScanner.getApkPaths(app.packageName.value)
|
||||
apkPaths.addAll(paths)
|
||||
}
|
||||
|
||||
// Create metadata directory
|
||||
val metaDir = File(cacheDir, "streaming_meta")
|
||||
metaDir.mkdirs()
|
||||
|
||||
// Write app list
|
||||
val appListFile = File(metaDir, "appList.txt")
|
||||
appListFile.writeText(apps.joinToString("\n") { it.packageName.value })
|
||||
|
||||
// Write app_details.json
|
||||
val metaFile = File(metaDir, "app_details.json")
|
||||
metaFile.writeText(BackupOperation.buildAppDetailsJson(apps, legacyApps))
|
||||
|
||||
Log.i(TAG, "Streaming prepared: ${apkPaths.size} APKs, FIFO at ${fifo.absolutePath}")
|
||||
StreamingResult(apkPaths, fifo, metaDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the data producer in a root shell background process.
|
||||
*
|
||||
* For each app, runs `tar -cf - /data/data/pkg 2>/dev/null` and appends
|
||||
* to the FIFO. The FIFO is consumed by `restic backup --stdin`.
|
||||
*
|
||||
* @param apps Apps whose data directories to tar
|
||||
* @param noDataBackup Set of package names to exclude from data backup
|
||||
* @param userId Android user ID
|
||||
* @param fifoPath Path to the FIFO
|
||||
*/
|
||||
suspend fun launchDataProducer(
|
||||
apps: List<AppInfo>,
|
||||
noDataBackup: Set<String>,
|
||||
@Suppress("UNUSED_PARAMETER") userId: String,
|
||||
fifoPath: String
|
||||
): Boolean = withContext(Dispatchers.IO) {
|
||||
val fifoEsc = fifoPath.shellEscape()
|
||||
|
||||
for (app in apps) {
|
||||
if (!coroutineContext.isActive) return@withContext false
|
||||
|
||||
val pkgName = app.packageName.value
|
||||
if (pkgName in noDataBackup) {
|
||||
Log.d(TAG, "Skipping data for $pkgName (excluded)")
|
||||
continue
|
||||
}
|
||||
|
||||
val dataDir = "/data/data/$pkgName"
|
||||
// Check if data directory exists
|
||||
val existsResult = RootShell.exec("[ -d '${dataDir.shellEscape()}' ] && echo 1 || echo 0")
|
||||
if (existsResult.output.trim() != "1") {
|
||||
Log.d(TAG, "No data directory for $pkgName, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
// Append tar output to FIFO. `>>` blocks until consumer reads.
|
||||
val cmd = "tar -cf - '$dataDir' 2>/dev/null >> '$fifoEsc'"
|
||||
Log.d(TAG, "Streaming data for $pkgName: $cmd")
|
||||
val result = RootShell.exec(cmd)
|
||||
if (!result.isSuccess) {
|
||||
Log.w(TAG, "Data backup failed for $pkgName: ${result.error}")
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "Data producer completed")
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,6 @@ package com.example.androidbackupgui.ui
|
||||
enum class Screen(val label: String, val icon: String) {
|
||||
BACKUP("应用备份", "backup"),
|
||||
RESTORE("应用恢复", "restore"),
|
||||
CONFIG("备份配置", "settings")
|
||||
CONFIG("备份配置", "settings"),
|
||||
LOG("运行日志", "logs")
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.example.androidbackupgui.ui
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.Restore
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
@@ -13,6 +14,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
private val navItems = listOf(
|
||||
NavItem(Screen.BACKUP, Icons.Filled.Cloud, "备份"),
|
||||
NavItem(Screen.RESTORE, Icons.Filled.Restore, "恢复"),
|
||||
NavItem(Screen.LOG, Icons.Filled.Description, "日志"),
|
||||
NavItem(Screen.CONFIG, Icons.Filled.Settings, "配置"),
|
||||
)
|
||||
|
||||
@@ -59,6 +61,7 @@ fun AppScaffold() {
|
||||
when (currentScreen) {
|
||||
Screen.BACKUP -> BackupScreen()
|
||||
Screen.RESTORE -> RestoreScreen()
|
||||
Screen.LOG -> LogScreen()
|
||||
Screen.CONFIG -> ConfigScreen(snackbarHostState = snackbarHostState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SortByAlpha
|
||||
@@ -15,89 +13,35 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.example.androidbackupgui.backup.*
|
||||
import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_START_BACKUP
|
||||
import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_STOP_BACKUP
|
||||
import com.example.androidbackupgui.backup.BackupService.Companion.EXTRA_STATUS_TEXT
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.ResticBinary
|
||||
import com.example.androidbackupgui.backup.WifiManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
private enum class SortMode { NAME_ASC, SIZE_DESC }
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.androidbackupgui.backup.AppInfo
|
||||
|
||||
/**
|
||||
* 备份主页——应用选择、扫描和备份执行。
|
||||
*
|
||||
* 业务逻辑在 [BackupViewModel] 中,UI 只负责渲染和事件转发。
|
||||
*/
|
||||
@Composable
|
||||
fun BackupScreen() {
|
||||
fun BackupScreen(viewModel: BackupViewModel = viewModel()) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
// ── State ──
|
||||
var config by remember { mutableStateOf(BackupConfig()) }
|
||||
var allApps by remember { mutableStateOf<List<AppInfo>>(emptyList()) }
|
||||
var sortedApps by remember { mutableStateOf<List<AppInfo>>(emptyList()) }
|
||||
var selectedApps by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
var excludeDataFromBackup by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
var sortMode by remember { mutableStateOf(SortMode.NAME_ASC) }
|
||||
var showSystemApps by remember { mutableStateOf(false) }
|
||||
var statusText by remember { mutableStateOf("请先扫描应用") }
|
||||
var isRunning by remember { mutableStateOf(false) }
|
||||
var isScanning by remember { mutableStateOf(false) }
|
||||
|
||||
// Load config
|
||||
LaunchedEffect(Unit) {
|
||||
config = BackupConfig.fromFile(File(context.filesDir, "backup_settings.conf"))
|
||||
}
|
||||
|
||||
// Re-apply sort/filter when dependencies change
|
||||
LaunchedEffect(allApps, sortMode, showSystemApps) {
|
||||
val filtered = if (showSystemApps) allApps else allApps.filter { !it.isSystem }
|
||||
val sorted = when (sortMode) {
|
||||
SortMode.NAME_ASC -> filtered.sortedBy { it.label.lowercase(Locale.US) }
|
||||
SortMode.SIZE_DESC -> filtered.sortedByDescending { it.backupSize }
|
||||
}
|
||||
sortedApps = sorted
|
||||
LaunchedEffect(state.allApps, state.sortMode, state.showSystemApps) {
|
||||
viewModel.applySortAndFilter()
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Top controls card ──
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
||||
// Scan button
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
isScanning = true
|
||||
statusText = "正在扫描应用…"
|
||||
scope.launch {
|
||||
try {
|
||||
val userId = config.backupUserId
|
||||
val thirdParty = withContext(Dispatchers.IO) {
|
||||
AppScanner.scanThirdParty(context, userId = userId)
|
||||
}
|
||||
val system = withContext(Dispatchers.IO) {
|
||||
AppScanner.scanSystem(context, config, userId = userId)
|
||||
}
|
||||
val apps = if (showSystemApps) thirdParty + system else thirdParty
|
||||
allApps = apps
|
||||
selectedApps = apps.map { it.packageName.value }.toSet()
|
||||
statusText = "共找到 ${apps.size} 个应用,全部已选中"
|
||||
} catch (e: Exception) {
|
||||
statusText = "扫描应用失败: ${e.message}"
|
||||
} finally {
|
||||
isScanning = false
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isScanning && !isRunning,
|
||||
modifier = Modifier.weight(1f)
|
||||
onClick = { viewModel.scanApps(context) },
|
||||
enabled = !state.isScanning && !state.isRunning,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
if (isScanning) {
|
||||
if (state.isScanning) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
@@ -108,198 +52,67 @@ fun BackupScreen() {
|
||||
// Sort/filter row
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
FilterChip(
|
||||
selected = sortMode == SortMode.NAME_ASC,
|
||||
onClick = {
|
||||
sortMode = SortMode.NAME_ASC
|
||||
},
|
||||
selected = state.sortMode == SortMode.NAME_ASC,
|
||||
onClick = { viewModel.setSortMode(SortMode.NAME_ASC) },
|
||||
label = { Text("A-Z") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.SortByAlpha, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
leadingIcon = { Icon(Icons.Default.SortByAlpha, contentDescription = null, modifier = Modifier.size(16.dp)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = sortMode == SortMode.SIZE_DESC,
|
||||
onClick = {
|
||||
sortMode = SortMode.SIZE_DESC
|
||||
},
|
||||
selected = state.sortMode == SortMode.SIZE_DESC,
|
||||
onClick = { viewModel.setSortMode(SortMode.SIZE_DESC) },
|
||||
label = { Text("大小") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Storage, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
leadingIcon = { Icon(Icons.Default.Storage, contentDescription = null, modifier = Modifier.size(16.dp)) },
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TextButton(onClick = {
|
||||
selectedApps = sortedApps.map { it.packageName.value }.toSet()
|
||||
}) { Text("全选") }
|
||||
TextButton(onClick = { selectedApps = emptySet() }) { Text("取消全选") }
|
||||
TextButton(onClick = { viewModel.selectAll() }) { Text("全选") }
|
||||
TextButton(onClick = { viewModel.clearSelection() }) { Text("取消全选") }
|
||||
}
|
||||
|
||||
// Show system switch
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("显示系统应用", modifier = Modifier.weight(1f))
|
||||
Switch(checked = showSystemApps, onCheckedChange = { showSystemApps = it })
|
||||
Switch(checked = state.showSystemApps, onCheckedChange = { viewModel.toggleShowSystem() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status ──
|
||||
Text(
|
||||
text = statusText,
|
||||
text = state.statusText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
|
||||
// ── App list ──
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(sortedApps, key = { it.packageName.value }) { app ->
|
||||
items(state.sortedApps, key = { it.packageName.value }) { app ->
|
||||
AppListItem(
|
||||
app = app,
|
||||
isSelected = app.packageName.value in selectedApps,
|
||||
isDataExcluded = app.packageName.value in excludeDataFromBackup,
|
||||
onToggle = { checked ->
|
||||
selectedApps = if (checked) selectedApps + app.packageName.value
|
||||
else selectedApps - app.packageName.value
|
||||
},
|
||||
onExcludeDataToggle = { excluded ->
|
||||
excludeDataFromBackup = if (excluded) excludeDataFromBackup + app.packageName.value
|
||||
else excludeDataFromBackup - app.packageName.value
|
||||
}
|
||||
isSelected = app.packageName.value in state.selectedApps,
|
||||
isDataExcluded = app.packageName.value in state.excludeDataFromBackup,
|
||||
onToggle = { checked -> viewModel.toggleApp(app.packageName.value, checked) },
|
||||
onExcludeDataToggle = { excluded -> viewModel.toggleExcludeData(app.packageName.value, excluded) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bottom bar with backup button ──
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tonalElevation = 3.dp
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxWidth(), tonalElevation = 3.dp) {
|
||||
Button(
|
||||
onClick = {
|
||||
val toBackup = allApps.filter { it.packageName.value in selectedApps }
|
||||
if (toBackup.isEmpty()) return@Button
|
||||
isRunning = true
|
||||
statusText = "开始备份 ${toBackup.size} 个应用…"
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
// 1. Start foreground service
|
||||
val serviceIntent = Intent(context, BackupService::class.java).apply {
|
||||
action = ACTION_START_BACKUP
|
||||
putExtra(EXTRA_STATUS_TEXT, "正在备份 ${toBackup.size} 个应用…")
|
||||
}
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, serviceIntent)
|
||||
} catch (_: Exception) {}
|
||||
|
||||
// 2. Execute backup
|
||||
val outputDir = File(config.outputPath.ifEmpty {
|
||||
context.filesDir.absolutePath
|
||||
})
|
||||
val backupResult = withContext(Dispatchers.IO) {
|
||||
BackupOperation.backupApps(
|
||||
context = context,
|
||||
apps = toBackup,
|
||||
config = config,
|
||||
outputDir = outputDir,
|
||||
userId = config.backupUserId.toString(),
|
||||
noDataBackup = excludeDataFromBackup,
|
||||
onProgress = { progress ->
|
||||
statusText = "[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
|
||||
}
|
||||
)
|
||||
}
|
||||
statusText = "备份完成!成功: ${backupResult.successCount} 失败: ${backupResult.failCount} 耗时: ${backupResult.elapsedMs / 1000}s"
|
||||
|
||||
// 3. WiFi 备份
|
||||
WifiManager.backup(File(backupResult.outputDir))
|
||||
|
||||
// 4. Restic 上传(如启用)
|
||||
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
|
||||
val binaryPath = ResticBinary.prepare(context)
|
||||
if (binaryPath != null) {
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.cacheDir = context.cacheDir.absolutePath
|
||||
ResticWrapper.backendDomain = config.resticBackendDomain
|
||||
|
||||
statusText = "正在写入 restic 去重仓库…"
|
||||
val resticResult = withContext(Dispatchers.IO) {
|
||||
ResticWrapper.backup(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
paths = listOf(backupResult.outputDir),
|
||||
tags = listOf("backup_${System.currentTimeMillis() / 1000}"),
|
||||
hostname = "android-backup-gui",
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
onProgress = { progress ->
|
||||
if (progress.messageType == "status") {
|
||||
statusText = "去重仓库: %.0f%% (%d/%d 个文件)".format(
|
||||
progress.percentDone * 100,
|
||||
progress.filesDone,
|
||||
progress.totalFiles
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
when (resticResult) {
|
||||
is AppResult.Success -> {
|
||||
val summary = resticResult.getOrNull()
|
||||
statusText = buildString {
|
||||
appendLine("备份完成!")
|
||||
appendLine("成功: ${backupResult.successCount} 失败: ${backupResult.failCount}")
|
||||
appendLine("耗时: ${backupResult.elapsedMs / 1000}秒")
|
||||
appendLine("Restic ID: ${summary?.snapshotId?.take(8)}…")
|
||||
if (summary != null) {
|
||||
appendLine("新增: ${summary.dataAdded / 1024 / 1024} MB")
|
||||
}
|
||||
}
|
||||
}
|
||||
is AppResult.Failure -> {
|
||||
statusText = "restic 快照失败: ${resticResult.errorOrNull()?.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val errMsg = e.message ?: "未知错误"
|
||||
Log.e("BackupScreen", "备份异常", e)
|
||||
val hint = when {
|
||||
errMsg.contains("EPERM", ignoreCase = true) || errMsg.contains("Operation not permitted", ignoreCase = true) ->
|
||||
"写入备份目录被拒绝,请检查输出路径权限或改用内置存储"
|
||||
errMsg.contains("EACCES", ignoreCase = true) || errMsg.contains("Permission denied", ignoreCase = true) ->
|
||||
"权限不足,请检查存储权限"
|
||||
else -> null
|
||||
}
|
||||
statusText = if (hint != null) "备份异常: ${e.message} ($hint)" else "备份异常: ${e.message}"
|
||||
}
|
||||
finally {
|
||||
isRunning = false
|
||||
try {
|
||||
val stopIntent = Intent(context, BackupService::class.java).apply {
|
||||
action = ACTION_STOP_BACKUP
|
||||
}
|
||||
context.startService(stopIntent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isRunning && selectedApps.isNotEmpty(),
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp)
|
||||
onClick = { viewModel.executeBackup(context) },
|
||||
enabled = !state.isRunning && state.selectedApps.isNotEmpty(),
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
) {
|
||||
if (isRunning) {
|
||||
if (state.isRunning) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text("开始备份 (${selectedApps.size})")
|
||||
Text("开始备份 (${state.selectedApps.size})")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,27 +124,27 @@ private fun AppListItem(
|
||||
isSelected: Boolean,
|
||||
isDataExcluded: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
onExcludeDataToggle: (Boolean) -> Unit
|
||||
onExcludeDataToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
Card(
|
||||
onClick = { onToggle(!isSelected) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(checked = isSelected, onCheckedChange = { onToggle(it) })
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = app.label.ifEmpty { app.packageName.value },
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = app.packageName.value,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (isSelected) {
|
||||
@@ -339,8 +152,7 @@ private fun AppListItem(
|
||||
Text(
|
||||
"数据",
|
||||
textDecoration = if (isDataExcluded) TextDecoration.LineThrough else TextDecoration.None,
|
||||
color = if (isDataExcluded) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.primary
|
||||
color = if (isDataExcluded) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.androidbackupgui.backup.*
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_START_BACKUP
|
||||
import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_STOP_BACKUP
|
||||
import com.example.androidbackupgui.backup.BackupService.Companion.EXTRA_STATUS_TEXT
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
enum class SortMode { NAME_ASC, SIZE_DESC }
|
||||
|
||||
/** Backup 界面的完整 UI 状态。 */
|
||||
data class BackupUiState(
|
||||
val config: BackupConfig = BackupConfig(),
|
||||
val allApps: List<AppInfo> = emptyList(),
|
||||
val sortedApps: List<AppInfo> = emptyList(),
|
||||
val selectedApps: Set<String> = emptySet(),
|
||||
val excludeDataFromBackup: Set<String> = emptySet(),
|
||||
val sortMode: SortMode = SortMode.NAME_ASC,
|
||||
val showSystemApps: Boolean = false,
|
||||
val statusText: String = "请先扫描应用",
|
||||
val isRunning: Boolean = false,
|
||||
val isScanning: Boolean = false,
|
||||
)
|
||||
|
||||
/** 备份操作的一次性事件。 */
|
||||
sealed interface BackupEvent {
|
||||
data class Error(
|
||||
val message: String,
|
||||
) : BackupEvent
|
||||
|
||||
data class BackupCompleted(
|
||||
val result: BackupOperation.BackupResult,
|
||||
) : BackupEvent
|
||||
}
|
||||
|
||||
class BackupViewModel(
|
||||
application: Application,
|
||||
) : AndroidViewModel(application) {
|
||||
companion object {
|
||||
private const val TAG = "BackupViewModel"
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(BackupUiState())
|
||||
val state: StateFlow<BackupUiState> = _state.asStateFlow()
|
||||
|
||||
private var currentJob: Job? = null
|
||||
|
||||
init {
|
||||
// 加载配置文件
|
||||
val cfg = BackupConfig.fromFile(File(application.filesDir, "backup_settings.conf"))
|
||||
_state.update { it.copy(config = cfg) }
|
||||
}
|
||||
|
||||
// ── 应用列表排序/过滤 ──────────────────────────────
|
||||
|
||||
fun applySortAndFilter() {
|
||||
val s = _state.value
|
||||
val filtered = if (s.showSystemApps) s.allApps else s.allApps.filter { !it.isSystem }
|
||||
val sorted =
|
||||
when (s.sortMode) {
|
||||
SortMode.NAME_ASC -> filtered.sortedBy { it.label.lowercase(Locale.US) }
|
||||
SortMode.SIZE_DESC -> filtered.sortedByDescending { it.backupSize }
|
||||
}
|
||||
_state.update { it.copy(sortedApps = sorted) }
|
||||
}
|
||||
|
||||
fun setSortMode(mode: SortMode) {
|
||||
_state.update { it.copy(sortMode = mode) }
|
||||
applySortAndFilter()
|
||||
}
|
||||
|
||||
fun toggleShowSystem() {
|
||||
_state.update { it.copy(showSystemApps = !it.showSystemApps) }
|
||||
applySortAndFilter()
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
val pkgs =
|
||||
_state.value.sortedApps
|
||||
.map { it.packageName.value }
|
||||
.toSet()
|
||||
_state.update { it.copy(selectedApps = pkgs) }
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
_state.update { it.copy(selectedApps = emptySet()) }
|
||||
}
|
||||
|
||||
fun toggleApp(
|
||||
packageName: String,
|
||||
checked: Boolean,
|
||||
) {
|
||||
_state.update { s ->
|
||||
s.copy(selectedApps = if (checked) s.selectedApps + packageName else s.selectedApps - packageName)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleExcludeData(
|
||||
packageName: String,
|
||||
excluded: Boolean,
|
||||
) {
|
||||
_state.update { s ->
|
||||
s.copy(excludeDataFromBackup = if (excluded) s.excludeDataFromBackup + packageName else s.excludeDataFromBackup - packageName)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 扫描应用 ────────────────────────────────────────
|
||||
|
||||
fun scanApps(context: Context) {
|
||||
if (_state.value.isScanning) return
|
||||
_state.update { it.copy(isScanning = true, statusText = "正在扫描应用…") }
|
||||
val config = _state.value.config
|
||||
|
||||
currentJob =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val userId = config.backupUserId
|
||||
val thirdParty = withContext(Dispatchers.IO) { AppScanner.scanThirdParty(context, userId = userId) }
|
||||
val system = withContext(Dispatchers.IO) { AppScanner.scanSystem(context, config, userId = userId) }
|
||||
val apps = if (_state.value.showSystemApps) thirdParty + system else thirdParty
|
||||
|
||||
val allPkgNames = apps.map { it.packageName.value }.toSet()
|
||||
var excludeSet = emptySet<String>()
|
||||
|
||||
val appListFile = File(context.filesDir, "appList.txt")
|
||||
if (appListFile.exists()) {
|
||||
val content = appListFile.readText()
|
||||
val parsed = AppScanner.parseAppList(content)
|
||||
val fromPrefix = parsed.filter { it.first in allPkgNames && !it.second }.map { it.first }.toSet()
|
||||
if (fromPrefix.isNotEmpty()) excludeSet = fromPrefix
|
||||
}
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
allApps = apps,
|
||||
sortedApps = apps,
|
||||
selectedApps = allPkgNames,
|
||||
excludeDataFromBackup = excludeSet,
|
||||
statusText =
|
||||
if (excludeSet.isNotEmpty()) {
|
||||
"共找到 ${apps.size} 个应用,${excludeSet.size} 个标记为仅APK"
|
||||
} else {
|
||||
"共找到 ${apps.size} 个应用,全部已选中"
|
||||
},
|
||||
isScanning = false,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(statusText = "扫描应用失败: ${e.message}", isScanning = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 执行备份 ────────────────────────────────────────
|
||||
|
||||
fun executeBackup(context: Context) {
|
||||
val s = _state.value
|
||||
val toBackup = s.allApps.filter { it.packageName.value in s.selectedApps }
|
||||
if (toBackup.isEmpty()) return
|
||||
|
||||
_state.update { it.copy(isRunning = true, statusText = "开始备份 ${toBackup.size} 个应用…") }
|
||||
|
||||
currentJob =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// 1. 启动前台服务
|
||||
val serviceIntent =
|
||||
Intent(context, BackupService::class.java).apply {
|
||||
action = ACTION_START_BACKUP
|
||||
putExtra(EXTRA_STATUS_TEXT, "正在备份 ${toBackup.size} 个应用…")
|
||||
}
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, serviceIntent)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
// 2. 执行备份
|
||||
val outputDir = File(s.config.outputPath.ifEmpty { context.filesDir.absolutePath })
|
||||
val backupResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
BackupOperation.backupApps(
|
||||
context = context,
|
||||
apps = toBackup,
|
||||
config = s.config,
|
||||
outputDir = outputDir,
|
||||
userId = s.config.backupUserId.toString(),
|
||||
noDataBackup = s.excludeDataFromBackup,
|
||||
onProgress = { progress ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
statusText = "[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
statusText = "备份完成!成功: ${backupResult.successCount} 失败: ${backupResult.failCount} 耗时: ${backupResult.elapsedMs / 1000}s",
|
||||
)
|
||||
}
|
||||
|
||||
// 3. WiFi 备份
|
||||
if (s.config.backupWifi == 1) {
|
||||
WifiManager.backup(File(backupResult.outputDir))
|
||||
}
|
||||
|
||||
// 4. Restic 上传
|
||||
if (s.config.resticEnabled == 1 && s.config.resticRepo.isNotBlank()) {
|
||||
executeResticBackup(context, toBackup, s, backupResult)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val hint =
|
||||
when {
|
||||
e.message?.contains("EPERM", ignoreCase = true) == true -> "写入备份目录被拒绝,请检查输出路径权限"
|
||||
e.message?.contains("EACCES", ignoreCase = true) == true -> "权限不足,请检查存储权限"
|
||||
else -> null
|
||||
}
|
||||
_state.update { it.copy(statusText = "备份异常: ${e.message}" + (hint?.let { " ($it)" } ?: "")) }
|
||||
} finally {
|
||||
_state.update { it.copy(isRunning = false) }
|
||||
try {
|
||||
context.startService(Intent(context, BackupService::class.java).apply { action = ACTION_STOP_BACKUP })
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun executeResticBackup(
|
||||
context: Context,
|
||||
toBackup: List<AppInfo>,
|
||||
s: BackupUiState,
|
||||
backupResult: BackupOperation.BackupResult,
|
||||
) {
|
||||
val binaryPath = ResticBinary.prepare(context) ?: return
|
||||
defaultResticWrapper.binaryPath = binaryPath
|
||||
defaultResticWrapper.cacheDir = context.cacheDir.absolutePath
|
||||
defaultResticWrapper.backendDomain = s.config.resticBackendDomain
|
||||
val password = PasswordManager.getResticPassword() ?: s.config.resticPassword.takeIf { it.isNotEmpty() } ?: ""
|
||||
val backendPass = PasswordManager.getBackendPass() ?: s.config.resticBackendPass.takeIf { it.isNotEmpty() } ?: ""
|
||||
|
||||
if (s.config.useStreaming == 1) {
|
||||
defaultResticWrapper
|
||||
.backupStreaming(
|
||||
apps = toBackup,
|
||||
noDataBackup = s.excludeDataFromBackup,
|
||||
legacyApps = null,
|
||||
ownPackageName = context.packageName,
|
||||
userId = s.config.backupUserId.toString(),
|
||||
repoPath = s.config.resticRepo,
|
||||
password = password,
|
||||
tags = listOf("backup_${System.currentTimeMillis() / 1000}"),
|
||||
hostname = "android-backup-gui",
|
||||
backend = s.config.resticBackend,
|
||||
backendUrl = s.config.resticBackendUrl,
|
||||
backendUser = s.config.resticBackendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = s.config.resticBackendShare,
|
||||
onProgress = { msg -> _state.update { it.copy(statusText = msg) } },
|
||||
).let { result ->
|
||||
when (result) {
|
||||
is AppResult.Success -> {
|
||||
val summary = result.getOrNull()
|
||||
_state.update {
|
||||
it.copy(
|
||||
statusText = "流式备份完成!ID: ${summary?.snapshotId?.take(
|
||||
8,
|
||||
)}… 新增: ${(summary?.dataAdded ?: 0) / 1024 / 1024} MB",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
_state.update { it.copy(statusText = "流式备份失败: ${result.errorOrNull()?.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
defaultResticWrapper
|
||||
.backup(
|
||||
repoPath = s.config.resticRepo,
|
||||
password = password,
|
||||
paths = listOf(backupResult.outputDir),
|
||||
tags = listOf("backup_${System.currentTimeMillis() / 1000}"),
|
||||
hostname = "android-backup-gui",
|
||||
backend = s.config.resticBackend,
|
||||
backendUrl = s.config.resticBackendUrl,
|
||||
backendUser = s.config.resticBackendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = s.config.resticBackendShare,
|
||||
onProgress = { progress ->
|
||||
if (progress.messageType == "status") {
|
||||
_state.update {
|
||||
it.copy(
|
||||
statusText =
|
||||
"去重仓库: %.0f%% (%d/%d 个文件)".format(
|
||||
progress.percentDone * 100,
|
||||
progress.filesDone,
|
||||
progress.totalFiles,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
).let { result ->
|
||||
when (result) {
|
||||
is AppResult.Success -> {
|
||||
val summary = result.getOrNull()
|
||||
_state.update {
|
||||
it.copy(
|
||||
statusText = "备份完成!Restic ID: ${summary?.snapshotId?.take(
|
||||
8,
|
||||
)}… 新增: ${(summary?.dataAdded ?: 0) / 1024 / 1024} MB",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is AppResult.Failure -> {
|
||||
_state.update { it.copy(statusText = "restic 快照失败: ${result.errorOrNull()?.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import kotlinx.coroutines.withContext
|
||||
@Composable
|
||||
fun ConfigScreen(
|
||||
viewModel: ConfigViewModel = viewModel(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -56,6 +56,7 @@ fun ConfigScreen(
|
||||
var resticBackendPass by remember { mutableStateOf(config.resticBackendPass) }
|
||||
var resticBackendShare by remember { mutableStateOf(config.resticBackendShare) }
|
||||
var resticBackendDomain by remember { mutableStateOf(config.resticBackendDomain) }
|
||||
var streamingEnabled by remember { mutableStateOf(config.useStreaming == 1) }
|
||||
|
||||
// Sync local state from ViewModel when config reloads
|
||||
LaunchedEffect(config) {
|
||||
@@ -76,30 +77,35 @@ fun ConfigScreen(
|
||||
resticBackendPass = config.resticBackendPass
|
||||
resticBackendShare = config.resticBackendShare
|
||||
resticBackendDomain = config.resticBackendDomain
|
||||
streamingEnabled = config.useStreaming == 1
|
||||
}
|
||||
|
||||
// Load user list for backup user selector
|
||||
LaunchedEffect(Unit) {
|
||||
val users = withContext(Dispatchers.IO) {
|
||||
AppScanner.enumerateUsers()
|
||||
}
|
||||
val users =
|
||||
withContext(Dispatchers.IO) {
|
||||
AppScanner.enumerateUsers()
|
||||
}
|
||||
userList = users
|
||||
}
|
||||
|
||||
// Observe one-shot events → show Snackbar feedback
|
||||
LaunchedEffect(snackbarHostState) {
|
||||
viewModel.operationEvents.collect { event ->
|
||||
val msg = when (event) {
|
||||
is OperationEvent.InitCompleted -> "仓库初始化完成"
|
||||
is OperationEvent.InitFailed -> "仓库初始化失败"
|
||||
is OperationEvent.StatsCompleted -> "统计读取完成"
|
||||
is OperationEvent.PruneStarted -> "正在清理快照…"
|
||||
is OperationEvent.PruneCompleted -> "清理完成"
|
||||
is OperationEvent.PruneFailed -> "清理失败"
|
||||
is OperationEvent.ConfigExported -> "配置已导出"
|
||||
is OperationEvent.ConfigExportFailed -> "配置导出失败"
|
||||
else -> null
|
||||
}
|
||||
val msg =
|
||||
when (event) {
|
||||
is OperationEvent.InitCompleted -> "仓库初始化完成"
|
||||
is OperationEvent.InitFailed -> "仓库初始化失败"
|
||||
is OperationEvent.StatsCompleted -> "统计读取完成"
|
||||
is OperationEvent.PruneStarted -> "正在清理快照…"
|
||||
is OperationEvent.PruneCompleted -> "清理完成"
|
||||
is OperationEvent.PruneFailed -> "清理失败"
|
||||
is OperationEvent.ConfigExported -> "配置已导出"
|
||||
is OperationEvent.ConfigExportFailed -> "配置导出失败"
|
||||
is OperationEvent.ConfigImported -> "配置已导入"
|
||||
is OperationEvent.ConfigImportFailed -> "配置导入失败"
|
||||
else -> null
|
||||
}
|
||||
if (msg != null) {
|
||||
snackbarHostState.showSnackbar(msg)
|
||||
}
|
||||
@@ -109,18 +115,41 @@ fun ConfigScreen(
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
// SAF launcher: create a .conf document at a user-chosen location, then export.
|
||||
val exportLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/plain")
|
||||
) { uri ->
|
||||
if (uri != null) viewModel.exportConfig(uri)
|
||||
}
|
||||
val exportLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/plain"),
|
||||
) { uri ->
|
||||
if (uri != null) viewModel.exportConfig(uri)
|
||||
}
|
||||
|
||||
// SAF launcher: pick a .conf file to import.
|
||||
val importLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { uri ->
|
||||
if (uri != null) viewModel.importConfig(uri)
|
||||
}
|
||||
|
||||
// SAF directory picker for output path
|
||||
val dirPickerLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
val resolvedPath = resolveSafTreeUri(uri)
|
||||
if (resolvedPath != null) {
|
||||
outputPath = resolvedPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// ── Backup settings section ──
|
||||
Text("备份设置", style = MaterialTheme.typography.titleMedium)
|
||||
@@ -146,26 +175,35 @@ fun ConfigScreen(
|
||||
Text("忽略运行中的应用", modifier = Modifier.weight(1f))
|
||||
Switch(checked = ignoreRunning, onCheckedChange = { ignoreRunning = it })
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = outputPath,
|
||||
onValueChange = { outputPath = it },
|
||||
label = { Text("输出目录") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = outputPath,
|
||||
onValueChange = { outputPath = it },
|
||||
label = { Text("输出目录") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { dirPickerLauncher.launch(null) },
|
||||
modifier = Modifier.height(56.dp),
|
||||
) {
|
||||
Text("选择")
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = compressionMethod,
|
||||
onValueChange = { compressionMethod = it },
|
||||
label = { Text("压缩方式 (tar / zstd)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
// Backup user selector
|
||||
UserSelector(
|
||||
userList = userList,
|
||||
selectedUserId = backupUserId,
|
||||
onUserSelected = { backupUserId = it }
|
||||
onUserSelected = { backupUserId = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -183,10 +221,13 @@ fun ConfigScreen(
|
||||
if (resticEnabled) {
|
||||
OutlinedTextField(
|
||||
value = resticRepo,
|
||||
onValueChange = { resticRepo = it; viewModel.onFormChanged(resticBackend, it, resticBackendUrl) },
|
||||
onValueChange = {
|
||||
resticRepo = it
|
||||
viewModel.onFormChanged(resticBackend, it, resticBackendUrl)
|
||||
},
|
||||
label = { Text("仓库路径") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = resticPassword,
|
||||
@@ -194,7 +235,9 @@ fun ConfigScreen(
|
||||
label = { Text("仓库密码") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation()
|
||||
visualTransformation =
|
||||
androidx.compose.ui.text.input
|
||||
.PasswordVisualTransformation(),
|
||||
)
|
||||
|
||||
// Backend selection radio group
|
||||
@@ -203,22 +246,22 @@ fun ConfigScreen(
|
||||
Column(modifier = Modifier.selectableGroup()) {
|
||||
backends.forEach { (value, label) ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = resticBackend == value,
|
||||
onClick = {
|
||||
resticBackend = value
|
||||
viewModel.onFormChanged(value, resticRepo, resticBackendUrl)
|
||||
},
|
||||
role = Role.RadioButton
|
||||
)
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = resticBackend == value,
|
||||
onClick = {
|
||||
resticBackend = value
|
||||
viewModel.onFormChanged(value, resticRepo, resticBackendUrl)
|
||||
},
|
||||
role = Role.RadioButton,
|
||||
).padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = resticBackend == value,
|
||||
onClick = null
|
||||
onClick = null,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(label)
|
||||
@@ -231,67 +274,89 @@ fun ConfigScreen(
|
||||
Text(
|
||||
text = "实际仓库: ${backendDisplay.computedUrl}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// Remote-specific fields
|
||||
if (resticBackend != "local") {
|
||||
OutlinedTextField(
|
||||
value = resticBackendUrl,
|
||||
onValueChange = { resticBackendUrl = it; viewModel.onFormChanged(resticBackend, resticRepo, it) },
|
||||
label = { Text(backendDisplay.urlHint.ifEmpty { "后端地址" }) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
if (resticBackend == "webdav" || resticBackend == "smb") {
|
||||
OutlinedTextField(
|
||||
value = resticBackendUser,
|
||||
onValueChange = { resticBackendUser = it },
|
||||
label = { Text("用户名") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = resticBackendPass,
|
||||
onValueChange = { resticBackendPass = it },
|
||||
label = { Text("密码") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation()
|
||||
)
|
||||
}
|
||||
if (resticBackend == "smb") {
|
||||
OutlinedTextField(
|
||||
value = resticBackendShare,
|
||||
onValueChange = { resticBackendShare = it },
|
||||
label = { Text("SMB 共享名称") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = resticBackendDomain,
|
||||
onValueChange = { resticBackendDomain = it },
|
||||
label = { Text("SMB 域 (可选)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = resticBackendUrl,
|
||||
onValueChange = {
|
||||
resticBackendUrl = it
|
||||
viewModel.onFormChanged(resticBackend, resticRepo, it)
|
||||
},
|
||||
label = { Text(backendDisplay.urlHint.ifEmpty { "后端地址" }) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
if (resticBackend == "webdav" || resticBackend == "smb") {
|
||||
OutlinedTextField(
|
||||
value = resticBackendUser,
|
||||
onValueChange = { resticBackendUser = it },
|
||||
label = { Text("用户名") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = resticBackendPass,
|
||||
onValueChange = { resticBackendPass = it },
|
||||
label = { Text("密码") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation =
|
||||
androidx.compose.ui.text.input
|
||||
.PasswordVisualTransformation(),
|
||||
)
|
||||
}
|
||||
if (resticBackend == "smb") {
|
||||
OutlinedTextField(
|
||||
value = resticBackendShare,
|
||||
onValueChange = { resticBackendShare = it },
|
||||
label = { Text("SMB 共享名称") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = resticBackendDomain,
|
||||
onValueChange = { resticBackendDomain = it },
|
||||
label = { Text("SMB 域 (可选)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Streaming backup toggle ──
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"流式备份 (FIFO管道 → restic --stdin)",
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Switch(
|
||||
checked = streamingEnabled,
|
||||
onCheckedChange = { streamingEnabled = it },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Status & action buttons
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
text = status.message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -300,11 +365,20 @@ fun ConfigScreen(
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.initResticRepo(
|
||||
buildResticForm(resticRepo, resticPassword, resticBackend, resticBackendUrl, resticBackendUser, resticBackendPass, resticBackendShare, resticBackendDomain)
|
||||
buildResticForm(
|
||||
resticRepo,
|
||||
resticPassword,
|
||||
resticBackend,
|
||||
resticBackendUrl,
|
||||
resticBackendUser,
|
||||
resticBackendPass,
|
||||
resticBackendShare,
|
||||
resticBackendDomain,
|
||||
),
|
||||
)
|
||||
},
|
||||
enabled = status.initButtonEnabled,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("初始化仓库")
|
||||
}
|
||||
@@ -314,11 +388,20 @@ fun ConfigScreen(
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.showResticStats(
|
||||
buildResticForm(resticRepo, resticPassword, resticBackend, resticBackendUrl, resticBackendUser, resticBackendPass, resticBackendShare, resticBackendDomain)
|
||||
buildResticForm(
|
||||
resticRepo,
|
||||
resticPassword,
|
||||
resticBackend,
|
||||
resticBackendUrl,
|
||||
resticBackendUser,
|
||||
resticBackendPass,
|
||||
resticBackendShare,
|
||||
resticBackendDomain,
|
||||
),
|
||||
)
|
||||
},
|
||||
enabled = status.statsButtonEnabled,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("仓库统计")
|
||||
}
|
||||
@@ -328,11 +411,20 @@ fun ConfigScreen(
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
viewModel.pruneResticSnapshots(
|
||||
buildResticForm(resticRepo, resticPassword, resticBackend, resticBackendUrl, resticBackendUser, resticBackendPass, resticBackendShare, resticBackendDomain)
|
||||
buildResticForm(
|
||||
resticRepo,
|
||||
resticPassword,
|
||||
resticBackend,
|
||||
resticBackendUrl,
|
||||
resticBackendUser,
|
||||
resticBackendPass,
|
||||
resticBackendShare,
|
||||
resticBackendDomain,
|
||||
),
|
||||
)
|
||||
},
|
||||
enabled = status.pruneButtonEnabled,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("清理旧快照")
|
||||
}
|
||||
@@ -342,14 +434,24 @@ fun ConfigScreen(
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.unlockResticRepo(
|
||||
buildResticForm(resticRepo, resticPassword, resticBackend, resticBackendUrl, resticBackendUser, resticBackendPass, resticBackendShare, resticBackendDomain)
|
||||
buildResticForm(
|
||||
resticRepo,
|
||||
resticPassword,
|
||||
resticBackend,
|
||||
resticBackendUrl,
|
||||
resticBackendUser,
|
||||
resticBackendPass,
|
||||
resticBackendShare,
|
||||
resticBackendDomain,
|
||||
),
|
||||
)
|
||||
},
|
||||
enabled = status.unlockButtonEnabled,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiary,
|
||||
),
|
||||
) {
|
||||
Text("解锁仓库")
|
||||
}
|
||||
@@ -384,30 +486,42 @@ fun ConfigScreen(
|
||||
resticBackendPass = resticBackendPass,
|
||||
resticBackendShare = resticBackendShare,
|
||||
resticBackendDomain = resticBackendDomain,
|
||||
)
|
||||
useStreaming = if (streamingEnabled) 1 else 0,
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.Save, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("保存配置")
|
||||
}
|
||||
|
||||
// ── Export config button ──
|
||||
OutlinedButton(
|
||||
onClick = { exportLauncher.launch("backup_settings.conf") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
// ── Import / Export config buttons ──
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.FileUpload, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("导出配置")
|
||||
OutlinedButton(
|
||||
onClick = { importLauncher.launch(arrayOf("text/plain", "*/*")) },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("导入配置")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { exportLauncher.launch("backup_settings.conf") },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Icon(Icons.Filled.FileUpload, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("导出配置")
|
||||
}
|
||||
}
|
||||
if (resticEnabled && resticPassword.isNotEmpty()) {
|
||||
Text(
|
||||
text = "注意:导出的配置包含明文 Restic 密码,请妥善保管导出的文件。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -422,12 +536,13 @@ fun ConfigScreen(
|
||||
private fun UserSelector(
|
||||
userList: List<Pair<Int, String>>,
|
||||
selectedUserId: Int,
|
||||
onUserSelected: (Int) -> Unit
|
||||
onUserSelected: (Int) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val selectedName = userList.find { it.first == selectedUserId }?.let {
|
||||
"${it.second} (ID: ${it.first})"
|
||||
} ?: "Owner (ID: 0)"
|
||||
val selectedName =
|
||||
userList.find { it.first == selectedUserId }?.let {
|
||||
"${it.second} (ID: ${it.first})"
|
||||
} ?: "Owner (ID: 0)"
|
||||
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
OutlinedTextField(
|
||||
@@ -437,13 +552,16 @@ private fun UserSelector(
|
||||
label = { Text("备份用户") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth(),
|
||||
singleLine = true
|
||||
singleLine = true,
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
userList.forEach { (id, name) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("$name (ID: $id)") },
|
||||
onClick = { onUserSelected(id); expanded = false }
|
||||
onClick = {
|
||||
onUserSelected(id)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -452,13 +570,46 @@ private fun UserSelector(
|
||||
|
||||
/** Build a [ResticForm] from current input values (matches ConfigFragment's readResticForm). */
|
||||
private fun buildResticForm(
|
||||
repo: String, password: String,
|
||||
backend: String, backendUrl: String,
|
||||
backendUser: String, backendPass: String,
|
||||
backendShare: String, backendDomain: String
|
||||
repo: String,
|
||||
password: String,
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
backendDomain: String,
|
||||
) = ResticForm(
|
||||
repo = repo, password = password,
|
||||
backend = backend, backendUrl = backendUrl,
|
||||
backendUser = backendUser, backendPass = backendPass,
|
||||
backendShare = backendShare, backendDomain = backendDomain
|
||||
repo = repo,
|
||||
password = password,
|
||||
backend = backend,
|
||||
backendUrl = backendUrl,
|
||||
backendUser = backendUser,
|
||||
backendPass = backendPass,
|
||||
backendShare = backendShare,
|
||||
backendDomain = backendDomain,
|
||||
)
|
||||
|
||||
/**
|
||||
* 将 SAF OpenDocumentTree 的 content:// URI 转换为可用的文件系统路径。
|
||||
* SAF URI 示例: content://com.android.externalstorage.documents/tree/primary%3ADownload%2FBackup
|
||||
* 返回: /storage/emulated/0/Download/Backup
|
||||
*/
|
||||
private fun resolveSafTreeUri(uri: android.net.Uri): String? {
|
||||
// SAF tree URI 格式:
|
||||
// content://com.android.externalstorage.documents/tree/primary%3ADownload%2FBackup
|
||||
// lastPathSegment = primary%3ADownload%2FBackup 或 XXXX-XXXX%3Apath
|
||||
val docId = uri.lastPathSegment?.let { java.net.URLDecoder.decode(it, "UTF-8") } ?: return null
|
||||
|
||||
// docId 格式: primary:path/to/dir 或 XXXX-XXXX:path/to/dir
|
||||
val colonIdx = docId.indexOf(':')
|
||||
if (colonIdx < 0) return null
|
||||
|
||||
val storageId = docId.substring(0, colonIdx)
|
||||
val relPath = docId.substring(colonIdx + 1).trim('/')
|
||||
|
||||
return if (storageId.equals("primary", ignoreCase = true)) {
|
||||
"/storage/emulated/0/$relPath"
|
||||
} else {
|
||||
"/storage/$storageId/$relPath"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.androidbackupgui.backup.BackupConfig
|
||||
import com.example.androidbackupgui.backup.formatSize
|
||||
import com.example.androidbackupgui.backup.PasswordManager
|
||||
import com.example.androidbackupgui.backup.ResticBinary
|
||||
import com.example.androidbackupgui.backup.ResticWrapper
|
||||
import com.example.androidbackupgui.backup.defaultResticWrapper
|
||||
import com.example.androidbackupgui.backup.formatSize
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -27,7 +28,7 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
data class ConfigUiState(
|
||||
val config: BackupConfig = BackupConfig(),
|
||||
val backendDisplay: BackendDisplay = BackendDisplay(),
|
||||
val resticStatus: ResticStatus = ResticStatus()
|
||||
val resticStatus: ResticStatus = ResticStatus(),
|
||||
)
|
||||
|
||||
data class BackendDisplay(
|
||||
@@ -35,7 +36,7 @@ data class BackendDisplay(
|
||||
val needsAuth: Boolean = false,
|
||||
val isSmb: Boolean = false,
|
||||
val computedUrl: String = "",
|
||||
val urlHint: String = ""
|
||||
val urlHint: String = "",
|
||||
)
|
||||
|
||||
data class ResticStatus(
|
||||
@@ -48,15 +49,19 @@ data class ResticStatus(
|
||||
val pruneButtonVisible: Boolean = false,
|
||||
val pruneButtonEnabled: Boolean = true,
|
||||
val unlockButtonVisible: Boolean = false,
|
||||
val unlockButtonEnabled: Boolean = true
|
||||
val unlockButtonEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
/** Restic credential/form snapshot passed from Fragment on every user interaction. */
|
||||
data class ResticForm(
|
||||
val repo: String, val password: String,
|
||||
val backend: String, val backendUrl: String,
|
||||
val backendUser: String, val backendPass: String,
|
||||
val backendShare: String, val backendDomain: String
|
||||
val repo: String,
|
||||
val password: String,
|
||||
val backend: String,
|
||||
val backendUrl: String,
|
||||
val backendUser: String,
|
||||
val backendPass: String,
|
||||
val backendShare: String,
|
||||
val backendDomain: String,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -65,40 +70,61 @@ data class ResticForm(
|
||||
*/
|
||||
sealed interface OperationEvent {
|
||||
data object InitStarted : OperationEvent
|
||||
|
||||
data object InitCompleted : OperationEvent
|
||||
|
||||
data object InitFailed : OperationEvent
|
||||
|
||||
data object StatsStarted : OperationEvent
|
||||
|
||||
data object StatsCompleted : OperationEvent
|
||||
|
||||
data object PruneStarted : OperationEvent
|
||||
|
||||
data object PruneFailed : OperationEvent
|
||||
|
||||
data object PruneCompleted : OperationEvent
|
||||
|
||||
data object ConfigExported : OperationEvent
|
||||
|
||||
data object ConfigExportFailed : OperationEvent
|
||||
|
||||
data object ConfigImported : OperationEvent
|
||||
|
||||
data object ConfigImportFailed : OperationEvent
|
||||
}
|
||||
|
||||
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
class ConfigViewModel(
|
||||
application: Application,
|
||||
) : AndroidViewModel(application) {
|
||||
companion object {
|
||||
private const val TAG = "ConfigViewModel"
|
||||
private const val CONFIG_FILE_NAME = "backup_settings.conf"
|
||||
|
||||
fun deriveBackendDisplay(backend: String, repo: String, backendUrl: String): BackendDisplay {
|
||||
fun deriveBackendDisplay(
|
||||
backend: String,
|
||||
repo: String,
|
||||
backendUrl: String,
|
||||
): BackendDisplay {
|
||||
val isRemote = backend != "local"
|
||||
val needsAuth = backend == "webdav" || backend == "smb"
|
||||
val isSmb = backend == "smb"
|
||||
val urlHint = when (backend) {
|
||||
"webdav" -> "WebDAV 地址 (https://host:port/path)"
|
||||
"smb" -> "SMB 主机地址 (host 或 host:port)"
|
||||
"rest-server" -> "rest-server 地址 (http://host:port)"
|
||||
else -> ""
|
||||
}
|
||||
val computedUrl = ResticWrapper.buildRepoUrl(backend, repo, backendUrl)
|
||||
val urlHint =
|
||||
when (backend) {
|
||||
"webdav" -> "WebDAV 地址 (https://host:port/path)"
|
||||
"smb" -> "SMB 主机地址 (host 或 host:port)"
|
||||
"rest-server" -> "rest-server 地址 (http://host:port)"
|
||||
else -> ""
|
||||
}
|
||||
val computedUrl = defaultResticWrapper.buildRepoUrl(backend, repo, backendUrl)
|
||||
return BackendDisplay(
|
||||
isRemote = isRemote, needsAuth = needsAuth, isSmb = isSmb,
|
||||
computedUrl = computedUrl, urlHint = urlHint
|
||||
isRemote = isRemote,
|
||||
needsAuth = needsAuth,
|
||||
isSmb = isSmb,
|
||||
computedUrl = computedUrl,
|
||||
urlHint = urlHint,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val configFile: File by lazy {
|
||||
@@ -132,18 +158,40 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
refreshResticStatus(readResticForm())
|
||||
}
|
||||
|
||||
/** Build a [ResticForm] snapshot from the current state's config values. */
|
||||
private fun readResticForm() = _uiState.value.config.let { c ->
|
||||
ResticForm(
|
||||
repo = c.resticRepo, password = c.resticPassword,
|
||||
backend = c.resticBackend, backendUrl = c.resticBackendUrl,
|
||||
backendUser = c.resticBackendUser, backendPass = c.resticBackendPass,
|
||||
backendShare = c.resticBackendShare, backendDomain = c.resticBackendDomain
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Build a [ResticForm] snapshot from the current state's config values.
|
||||
* 密码从 PasswordManager(加密存储)获取,不从配置文件读取。
|
||||
*/
|
||||
private fun readResticForm() =
|
||||
_uiState.value.config.let { c ->
|
||||
// 从加密存储获取密码,如尚未设置则尝试从旧配置迁移
|
||||
val password = PasswordManager.getResticPassword() ?: c.resticPassword.takeIf { it.isNotEmpty() }
|
||||
val backendPass = PasswordManager.getBackendPass() ?: c.resticBackendPass.takeIf { it.isNotEmpty() }
|
||||
// 如果发现旧配置中有密码但 PasswordManager 还没有,迁移过去
|
||||
if (password != null && !PasswordManager.hasResticPassword() && password != "stored-in-keystore") {
|
||||
PasswordManager.setResticPassword(password)
|
||||
}
|
||||
if (backendPass != null && backendPass != "stored-in-keystore" && PasswordManager.getBackendPass() == null) {
|
||||
PasswordManager.setBackendPass(backendPass)
|
||||
}
|
||||
ResticForm(
|
||||
repo = c.resticRepo,
|
||||
password = password ?: "",
|
||||
backend = c.resticBackend,
|
||||
backendUrl = c.resticBackendUrl,
|
||||
backendUser = c.resticBackendUser,
|
||||
backendPass = backendPass ?: "",
|
||||
backendShare = c.resticBackendShare,
|
||||
backendDomain = c.resticBackendDomain,
|
||||
)
|
||||
}
|
||||
|
||||
/** Update derived display state when backend/repo/url form fields change. */
|
||||
fun onFormChanged(backend: String, repo: String, backendUrl: String) {
|
||||
fun onFormChanged(
|
||||
backend: String,
|
||||
repo: String,
|
||||
backendUrl: String,
|
||||
) {
|
||||
val bd = deriveBackendDisplay(backend, repo, backendUrl)
|
||||
_uiState.update { it.copy(backendDisplay = bd) }
|
||||
}
|
||||
@@ -151,21 +199,34 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
/**
|
||||
* Save config to file on IO and update status message.
|
||||
* The caller passes the current form values as a [BackupConfig] copy.
|
||||
* 密码单独通过 [PasswordManager] 安全存储,不入配置文件。
|
||||
*/
|
||||
fun save(formConfig: BackupConfig) {
|
||||
fun save(
|
||||
formConfig: BackupConfig,
|
||||
resticPassword: String? = null,
|
||||
backendPass: String? = null,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
// 保存密码到加密存储
|
||||
if (resticPassword != null && resticPassword.isNotEmpty()) {
|
||||
PasswordManager.setResticPassword(resticPassword)
|
||||
}
|
||||
if (backendPass != null && backendPass.isNotEmpty()) {
|
||||
PasswordManager.setBackendPass(backendPass)
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
BackupConfig.toFile(formConfig, configFile)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
config = formConfig,
|
||||
backendDisplay = deriveBackendDisplay(
|
||||
formConfig.resticBackend,
|
||||
formConfig.resticRepo,
|
||||
formConfig.resticBackendUrl
|
||||
),
|
||||
resticStatus = it.resticStatus.copy(message = "配置已保存到 $configFile")
|
||||
backendDisplay =
|
||||
deriveBackendDisplay(
|
||||
formConfig.resticBackend,
|
||||
formConfig.resticRepo,
|
||||
formConfig.resticBackendUrl,
|
||||
),
|
||||
resticStatus = it.resticStatus.copy(message = "配置已保存到 $configFile"),
|
||||
)
|
||||
}
|
||||
refreshResticStatus(readResticForm())
|
||||
@@ -179,31 +240,42 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
*/
|
||||
fun exportConfig(uri: android.net.Uri) {
|
||||
viewModelScope.launch {
|
||||
val ok = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Ensure the latest saved config exists; serialize current UI config
|
||||
// if the file isn't there yet.
|
||||
val content = if (configFile.exists()) {
|
||||
configFile.readText()
|
||||
} else {
|
||||
val tmp = File.createTempFile("cfg", ".conf", getApplication<Application>().cacheDir)
|
||||
BackupConfig.toFile(_uiState.value.config, tmp)
|
||||
tmp.readText().also { tmp.delete() }
|
||||
val ok =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Ensure the latest saved config exists; serialize current UI config
|
||||
// if the file isn't there yet.
|
||||
val content =
|
||||
if (configFile.exists()) {
|
||||
configFile.readText()
|
||||
} else {
|
||||
val tmp = File.createTempFile("cfg", ".conf", getApplication<Application>().cacheDir)
|
||||
BackupConfig.toFile(_uiState.value.config, tmp)
|
||||
tmp.readText().also { tmp.delete() }
|
||||
}
|
||||
getApplication<Application>()
|
||||
.contentResolver
|
||||
.openOutputStream(uri)
|
||||
?.use { out ->
|
||||
out.write(content.toByteArray())
|
||||
out.flush()
|
||||
} ?: return@withContext false
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "exportConfig failed", e)
|
||||
false
|
||||
}
|
||||
getApplication<Application>().contentResolver
|
||||
.openOutputStream(uri)?.use { out ->
|
||||
out.write(content.toByteArray())
|
||||
out.flush()
|
||||
} ?: return@withContext false
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "exportConfig failed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
_operationEvents.emit(OperationEvent.ConfigExported)
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "配置已导出")) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "配置已导出(密码未包含,需在目标设备上通过应用重新输入)",
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_operationEvents.emit(OperationEvent.ConfigExportFailed)
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "配置导出失败")) }
|
||||
@@ -211,13 +283,78 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import config from a user-selected [Uri] (SAF).
|
||||
* Reads the content, writes to configFile, and reloads UI state.
|
||||
*/
|
||||
fun importConfig(uri: android.net.Uri) {
|
||||
viewModelScope.launch {
|
||||
val ok =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val content =
|
||||
getApplication<Application>()
|
||||
.contentResolver
|
||||
.openInputStream(uri)
|
||||
?.use { input -> input.reader().readText() }
|
||||
?: return@withContext false
|
||||
configFile.writeText(content)
|
||||
val parsed = BackupConfig.fromFile(configFile)
|
||||
// 导入的配置中密码是 "stored-in-keystore" 占位符,
|
||||
// 需要从 PasswordManager 恢复真实密码,避免被覆盖
|
||||
val realResticPw = PasswordManager.getResticPassword()
|
||||
val realBackendPw = PasswordManager.getBackendPass()
|
||||
val restoredConfig =
|
||||
parsed.copy(
|
||||
resticPassword = realResticPw ?: parsed.resticPassword,
|
||||
resticBackendPass = realBackendPw ?: parsed.resticBackendPass,
|
||||
)
|
||||
_uiState.update { it.copy(config = restoredConfig) }
|
||||
Log.i(TAG, "importConfig: loaded config from SAF")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "importConfig failed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
_operationEvents.emit(OperationEvent.ConfigImported)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "配置已导入,请检查各项设置并保存",
|
||||
),
|
||||
)
|
||||
}
|
||||
// Reload UI state from imported config,保留已有的密码
|
||||
val s = _uiState.value
|
||||
refreshResticStatus(
|
||||
ResticForm(
|
||||
repo = s.config.resticRepo,
|
||||
password = PasswordManager.getResticPassword() ?: "",
|
||||
backend = s.config.resticBackend,
|
||||
backendUrl = s.config.resticBackendUrl,
|
||||
backendUser = s.config.resticBackendUser,
|
||||
backendPass = PasswordManager.getBackendPass() ?: "",
|
||||
backendShare = s.config.resticBackendShare,
|
||||
backendDomain = s.config.resticBackendDomain,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
_operationEvents.emit(OperationEvent.ConfigImportFailed)
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "配置导入失败")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepare ResticWrapper (binary, temp dir, domain) from application context. */
|
||||
private fun prepareRestic(): Boolean {
|
||||
val ctx = getApplication<Application>()
|
||||
val binaryPath = ResticBinary.prepare(ctx)
|
||||
if (binaryPath == null) return false
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.cacheDir = ctx.cacheDir.absolutePath
|
||||
defaultResticWrapper.binaryPath = binaryPath
|
||||
defaultResticWrapper.cacheDir = ctx.cacheDir.absolutePath
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -231,12 +368,17 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
Log.i(TAG, "initResticRepo called: repo=${form.repo} backend=${form.backend}")
|
||||
|
||||
if (!prepareRestic()) {
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "restic 二进制未就绪,请确保已安装 restic 于 Termux 或 APK 内置版本可用"
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "restic 二进制未就绪,请确保已安装 restic 于 Termux 或 APK 内置版本可用",
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
ResticWrapper.backendDomain = form.backendDomain
|
||||
defaultResticWrapper.backendDomain = form.backendDomain
|
||||
Log.i(TAG, "initResticRepo: repo=${form.repo} backend=${form.backend} url=${form.backendUrl}")
|
||||
|
||||
if (form.repo.isEmpty() || form.password.isEmpty()) {
|
||||
@@ -244,30 +386,51 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "正在初始化 restic 仓库…", initButtonEnabled = false
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "正在初始化 restic 仓库…",
|
||||
initButtonEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_operationEvents.emit(OperationEvent.InitStarted)
|
||||
val result = ResticWrapper.init(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
val result =
|
||||
defaultResticWrapper.init(
|
||||
form.repo,
|
||||
form.password,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
if (result.isSuccess) {
|
||||
_operationEvents.emit(OperationEvent.InitCompleted)
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "仓库初始化成功: ${form.repo}"
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "仓库初始化成功: ${form.repo}",
|
||||
),
|
||||
)
|
||||
}
|
||||
refreshResticStatus(form)
|
||||
} else {
|
||||
_operationEvents.emit(OperationEvent.InitFailed)
|
||||
Log.e(TAG, "initResticRepo failed: ${result.exceptionOrNull()?.message}")
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "初始化失败: ${result.exceptionOrNull()?.message}"
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "初始化失败: ${result.exceptionOrNull()?.message}",
|
||||
),
|
||||
)
|
||||
}
|
||||
refreshResticStatus(form)
|
||||
}
|
||||
} finally {
|
||||
@@ -278,131 +441,229 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
fun refreshResticStatus(form: ResticForm) {
|
||||
if (form.repo.isBlank()) {
|
||||
_uiState.update { it.copy(resticStatus = ResticStatus(
|
||||
message = "请填写仓库路径和密码后初始化",
|
||||
initButtonVisible = true, statsButtonVisible = false, pruneButtonVisible = false
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
ResticStatus(
|
||||
message = "请填写仓库路径和密码后初始化",
|
||||
initButtonVisible = true,
|
||||
statsButtonVisible = false,
|
||||
pruneButtonVisible = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!prepareRestic()) {
|
||||
_uiState.update { it.copy(resticStatus = ResticStatus(
|
||||
message = "restic 二进制未就绪",
|
||||
initButtonVisible = true, statsButtonVisible = false, pruneButtonVisible = false
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
ResticStatus(
|
||||
message = "restic 二进制未就绪",
|
||||
initButtonVisible = true,
|
||||
statsButtonVisible = false,
|
||||
pruneButtonVisible = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
ResticWrapper.backendDomain = form.backendDomain
|
||||
defaultResticWrapper.backendDomain = form.backendDomain
|
||||
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "正在检测仓库状态…")) }
|
||||
|
||||
// Cancel any stale status check so a slow old coroutine doesn't overwrite new results
|
||||
refreshJob?.cancel()
|
||||
refreshJob = viewModelScope.launch {
|
||||
val snapshotsResult = ResticWrapper.listSnapshots(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
if (snapshotsResult.isSuccess) {
|
||||
val snapshots = snapshotsResult.getOrDefault(emptyList())
|
||||
_uiState.update { it.copy(resticStatus = ResticStatus(
|
||||
message = "仓库就绪,${snapshots.size} 个快照",
|
||||
snapshotCount = snapshots.size,
|
||||
initButtonVisible = false, statsButtonVisible = true, pruneButtonVisible = true,
|
||||
unlockButtonVisible = true
|
||||
))}
|
||||
} else {
|
||||
val errMsg = snapshotsResult.errorOrNull()?.message ?: ""
|
||||
val hasLock = errMsg.contains("lock", ignoreCase = true) || errMsg.contains("already locked", ignoreCase = true)
|
||||
|
||||
if (hasLock) {
|
||||
_uiState.update { it.copy(resticStatus = ResticStatus(
|
||||
message = "仓库被锁定,请先解锁",
|
||||
initButtonVisible = false, statsButtonVisible = false, pruneButtonVisible = false,
|
||||
unlockButtonVisible = true
|
||||
))}
|
||||
} else {
|
||||
// snapshots 失败时自动尝试 init(处理已初始化的旧仓库)
|
||||
val initResult = ResticWrapper.init(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
refreshJob =
|
||||
viewModelScope.launch {
|
||||
val snapshotsResult =
|
||||
defaultResticWrapper.listSnapshots(
|
||||
form.repo,
|
||||
form.password,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
if (initResult.isSuccess) {
|
||||
val snaps = ResticWrapper.listSnapshots(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
).getOrDefault(emptyList())
|
||||
_uiState.update { it.copy(resticStatus = ResticStatus(
|
||||
message = "仓库就绪,${snaps.size} 个快照",
|
||||
snapshotCount = snaps.size,
|
||||
initButtonVisible = false, statsButtonVisible = true, pruneButtonVisible = true,
|
||||
unlockButtonVisible = true
|
||||
))}
|
||||
if (snapshotsResult.isSuccess) {
|
||||
val snapshots = snapshotsResult.getOrDefault(emptyList())
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
ResticStatus(
|
||||
message = "仓库就绪,${snapshots.size} 个快照",
|
||||
snapshotCount = snapshots.size,
|
||||
initButtonVisible = false,
|
||||
statsButtonVisible = true,
|
||||
pruneButtonVisible = true,
|
||||
unlockButtonVisible = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val errMsg = snapshotsResult.errorOrNull()?.message ?: ""
|
||||
val hasLock = errMsg.contains("lock", ignoreCase = true) || errMsg.contains("already locked", ignoreCase = true)
|
||||
|
||||
if (hasLock) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
ResticStatus(
|
||||
message = "仓库被锁定,请先解锁",
|
||||
initButtonVisible = false,
|
||||
statsButtonVisible = false,
|
||||
pruneButtonVisible = false,
|
||||
unlockButtonVisible = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update { it.copy(resticStatus = ResticStatus(
|
||||
message = "仓库未初始化或认证失败",
|
||||
initButtonVisible = true, statsButtonVisible = false, pruneButtonVisible = false,
|
||||
unlockButtonVisible = false
|
||||
))}
|
||||
// snapshots 失败时自动尝试 init(处理已初始化的旧仓库)
|
||||
val initResult =
|
||||
defaultResticWrapper.init(
|
||||
form.repo,
|
||||
form.password,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
if (initResult.isSuccess) {
|
||||
val snaps =
|
||||
defaultResticWrapper
|
||||
.listSnapshots(
|
||||
form.repo,
|
||||
form.password,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
).getOrDefault(emptyList())
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
ResticStatus(
|
||||
message = "仓库就绪,${snaps.size} 个快照",
|
||||
snapshotCount = snaps.size,
|
||||
initButtonVisible = false,
|
||||
statsButtonVisible = true,
|
||||
pruneButtonVisible = true,
|
||||
unlockButtonVisible = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
ResticStatus(
|
||||
message = "仓库未初始化或认证失败",
|
||||
initButtonVisible = true,
|
||||
statsButtonVisible = false,
|
||||
pruneButtonVisible = false,
|
||||
unlockButtonVisible = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unlockResticRepo(form: ResticForm) {
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "正在解锁仓库…", unlockButtonEnabled = false
|
||||
))}
|
||||
viewModelScope.launch {
|
||||
ResticWrapper.backendDomain = form.backendDomain
|
||||
val result = ResticWrapper.unlock(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "正在解锁仓库…",
|
||||
unlockButtonEnabled = false,
|
||||
),
|
||||
)
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = if (result.isSuccess) "解锁完成" else "解锁失败: ${result.errorOrNull()?.message}",
|
||||
unlockButtonEnabled = true
|
||||
))}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
defaultResticWrapper.backendDomain = form.backendDomain
|
||||
val result =
|
||||
defaultResticWrapper.unlock(
|
||||
form.repo,
|
||||
form.password,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = if (result.isSuccess) "解锁完成" else "解锁失败: ${result.errorOrNull()?.message}",
|
||||
unlockButtonEnabled = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
refreshResticStatus(form)
|
||||
}
|
||||
}
|
||||
|
||||
fun showResticStats(form: ResticForm) {
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "正在读取统计…", statsButtonEnabled = false
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "正在读取统计…",
|
||||
statsButtonEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_operationEvents.emit(OperationEvent.StatsStarted)
|
||||
val statsResult = ResticWrapper.stats(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
val snapshotsResult = ResticWrapper.listSnapshots(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
val statsResult =
|
||||
defaultResticWrapper.stats(
|
||||
form.repo,
|
||||
form.password,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
val snapshotsResult =
|
||||
defaultResticWrapper.listSnapshots(
|
||||
form.repo,
|
||||
form.password,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
|
||||
val snapshotCount = snapshotsResult.getOrDefault(emptyList()).size
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = buildString {
|
||||
appendLine("快照数: $snapshotCount")
|
||||
if (statsResult.isSuccess) {
|
||||
appendLine(statsResult.getOrDefault(""))
|
||||
} else {
|
||||
appendLine("统计读取失败: ${statsResult.errorOrNull()?.message}")
|
||||
}
|
||||
},
|
||||
snapshotCount = snapshotCount,
|
||||
statsButtonEnabled = true
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message =
|
||||
buildString {
|
||||
appendLine("快照数: $snapshotCount")
|
||||
if (statsResult.isSuccess) {
|
||||
appendLine(statsResult.getOrDefault(""))
|
||||
} else {
|
||||
appendLine("统计读取失败: ${statsResult.errorOrNull()?.message}")
|
||||
}
|
||||
},
|
||||
snapshotCount = snapshotCount,
|
||||
statsButtonEnabled = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
_operationEvents.emit(OperationEvent.StatsCompleted)
|
||||
} finally {
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(statsButtonEnabled = true)) }
|
||||
@@ -411,52 +672,85 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
|
||||
fun pruneResticSnapshots(form: ResticForm) {
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "正在清理旧快照 (保留 7 天 / 4 周 / 3 月)…",
|
||||
pruneButtonEnabled = false
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "正在清理旧快照 (保留 7 天 / 4 周 / 3 月)…",
|
||||
pruneButtonEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_operationEvents.emit(OperationEvent.PruneStarted)
|
||||
|
||||
// Remove stale locks before forget/prune
|
||||
ResticWrapper.backendDomain = form.backendDomain
|
||||
ResticWrapper.unlock(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
defaultResticWrapper.backendDomain = form.backendDomain
|
||||
defaultResticWrapper.unlock(
|
||||
form.repo,
|
||||
form.password,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
|
||||
val forgetResult = ResticWrapper.forget(form.repo, form.password,
|
||||
keepDaily = 7, keepWeekly = 4, keepMonthly = 3,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
val forgetResult =
|
||||
defaultResticWrapper.forget(
|
||||
form.repo,
|
||||
form.password,
|
||||
keepDaily = 7,
|
||||
keepWeekly = 4,
|
||||
keepMonthly = 3,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
if (forgetResult.isFailure) {
|
||||
_operationEvents.emit(OperationEvent.PruneFailed)
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "forget 失败: ${forgetResult.exceptionOrNull()?.message}",
|
||||
pruneButtonEnabled = true
|
||||
))}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message = "forget 失败: ${forgetResult.exceptionOrNull()?.message}",
|
||||
pruneButtonEnabled = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "正在回收空间…")) }
|
||||
|
||||
val pruneResult = ResticWrapper.prune(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = if (pruneResult.isSuccess)
|
||||
"清理完成!\n${pruneResult.getOrDefault("")}"
|
||||
else
|
||||
"prune 失败: ${pruneResult.exceptionOrNull()?.message}",
|
||||
pruneButtonEnabled = true
|
||||
))}
|
||||
val pruneResult =
|
||||
defaultResticWrapper.prune(
|
||||
form.repo,
|
||||
form.password,
|
||||
backend = form.backend,
|
||||
backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser,
|
||||
backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resticStatus =
|
||||
it.resticStatus.copy(
|
||||
message =
|
||||
if (pruneResult.isSuccess) {
|
||||
"清理完成!建议执行完整性检查 (check --read-data-subset=5%)"
|
||||
} else {
|
||||
"prune 失败: ${pruneResult.exceptionOrNull()?.message}"
|
||||
},
|
||||
pruneButtonEnabled = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (pruneResult.isSuccess) {
|
||||
_operationEvents.emit(OperationEvent.PruneCompleted)
|
||||
} else {
|
||||
@@ -467,6 +761,4 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
202
app/src/main/java/com/example/androidbackupgui/ui/LogScreen.kt
Normal file
202
app/src/main/java/com/example/androidbackupgui/ui/LogScreen.kt
Normal file
@@ -0,0 +1,202 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.androidbackupgui.backup.LogUtil
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LogScreen() {
|
||||
val context = LocalContext.current
|
||||
var logFiles by remember { mutableStateOf(listOf<File>()) }
|
||||
var selectedFile by remember { mutableStateOf<File?>(null) }
|
||||
var logContent by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Refresh log list
|
||||
fun refresh() {
|
||||
logFiles = LogUtil.getLogFiles()
|
||||
if (selectedFile != null && selectedFile !in logFiles) {
|
||||
selectedFile = null
|
||||
logContent = emptyList()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) { refresh() }
|
||||
|
||||
// SAF export launcher
|
||||
val exportLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/plain")
|
||||
) { uri ->
|
||||
if (uri != null && selectedFile != null) {
|
||||
exportLogFile(context, uri, selectedFile!!)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
// ── Header ──
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("运行日志", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = { refresh() }) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "刷新")
|
||||
}
|
||||
}
|
||||
|
||||
if (logFiles.isEmpty()) {
|
||||
Text(
|
||||
"暂无日志文件",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// ── Log file list ──
|
||||
Text("日志文件", style = MaterialTheme.typography.labelLarge)
|
||||
LazyColumn(
|
||||
modifier = Modifier.heightIn(max = 160.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
items(logFiles, key = { it.absolutePath }) { file ->
|
||||
val isSelected = file == selectedFile
|
||||
Card(
|
||||
onClick = {
|
||||
selectedFile = file
|
||||
scope.launch {
|
||||
logContent = withContext(Dispatchers.IO) {
|
||||
file.readLines()
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSelected)
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = file.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "${file.length() / 1024}KB",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// ── Action buttons ──
|
||||
if (selectedFile != null) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { exportLauncher.launch(selectedFile!!.name) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Filled.FileDownload, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("导出")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
selectedFile!!.delete()
|
||||
refresh()
|
||||
},
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("删除")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// ── Log content ──
|
||||
Text(
|
||||
"日志内容 — ${selectedFile!!.name}",
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
if (logContent.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("(空)", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
} else {
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
// Show last 500 lines (newest at bottom)
|
||||
val displayLines = logContent.takeLast(500)
|
||||
for (line in displayLines) {
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportLogFile(context: Context, uri: Uri, file: File) {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(uri)?.use { out ->
|
||||
file.inputStream().use { `in` ->
|
||||
`in`.copyTo(out)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("LogScreen", "导出日志失败", e)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -10,6 +12,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.androidbackupgui.backup.*
|
||||
import com.example.androidbackupgui.backup.defaultResticWrapper
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -34,6 +37,29 @@ fun RestoreScreen() {
|
||||
var availableSnapshots by remember { mutableStateOf<List<ResticWrapper.ResticSnapshot>>(emptyList()) }
|
||||
val configFile = remember { File(context.filesDir, "backup_settings.conf") }
|
||||
|
||||
// SAF directory picker for selecting external backup dir
|
||||
val dirPickerLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
val resolvedPath = resolveSafTreeUri(uri)
|
||||
if (resolvedPath != null) {
|
||||
val dir = File(resolvedPath)
|
||||
backupDir = dir
|
||||
selectedSnapshot = null
|
||||
scope.launch {
|
||||
loadFromDir(context, dir) { pkgs, infos, status ->
|
||||
packages = pkgs
|
||||
appInfos = infos
|
||||
selectedPackages = pkgs.toSet()
|
||||
statusText = status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load config
|
||||
LaunchedEffect(Unit) {
|
||||
config = BackupConfig.fromFile(configFile)
|
||||
@@ -46,7 +72,6 @@ fun RestoreScreen() {
|
||||
// ── Top controls card ──
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
||||
// Source buttons row
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
@@ -54,17 +79,20 @@ fun RestoreScreen() {
|
||||
scope.launch {
|
||||
try {
|
||||
val defaultDir = context.filesDir
|
||||
val backupDirs = withContext(Dispatchers.IO) {
|
||||
defaultDir.listFiles()
|
||||
?.filter { it.isDirectory && it.name.startsWith("Backup_") }
|
||||
?: emptyList()
|
||||
}
|
||||
val backupDirs =
|
||||
withContext(Dispatchers.IO) {
|
||||
defaultDir
|
||||
.listFiles()
|
||||
?.filter { it.isDirectory && it.name.startsWith("Backup_") }
|
||||
?: emptyList()
|
||||
}
|
||||
if (backupDirs.isNotEmpty()) {
|
||||
val dir = backupDirs.first()
|
||||
backupDir = dir
|
||||
selectedSnapshot = null
|
||||
loadFromDir(context, dir) { pkgs, infos, status ->
|
||||
packages = pkgs; appInfos = infos
|
||||
packages = pkgs
|
||||
appInfos = infos
|
||||
selectedPackages = pkgs.toSet()
|
||||
statusText = status
|
||||
}
|
||||
@@ -77,31 +105,54 @@ fun RestoreScreen() {
|
||||
}
|
||||
},
|
||||
enabled = !isRunning,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("本地备份")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { dirPickerLauncher.launch(null) },
|
||||
enabled = !isRunning,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("选择目录")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val config = resticConfig ?: run {
|
||||
statusText = "未配置 Restic,请先在设置中配置"
|
||||
return@Button
|
||||
}
|
||||
val config =
|
||||
resticConfig ?: run {
|
||||
statusText = "未配置 Restic,请先在设置中配置"
|
||||
return@Button
|
||||
}
|
||||
scope.launch {
|
||||
isRunning = true
|
||||
statusText = "正在读取快照…"
|
||||
try {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
ResticWrapper.listSnapshots(
|
||||
config.resticRepo, config.resticPassword,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
)
|
||||
}
|
||||
// 配置 ResticWrapper 环境
|
||||
defaultResticWrapper.cacheDir = context.cacheDir.absolutePath
|
||||
defaultResticWrapper.backendDomain = config.resticBackendDomain
|
||||
ResticBinary.prepare(context)?.let { defaultResticWrapper.binaryPath = it }
|
||||
|
||||
// 从 PasswordManager 恢复密码(过滤掉占位符)
|
||||
fun configPw(
|
||||
key: String?,
|
||||
fallback: String,
|
||||
): String = key?.takeIf { it.isNotEmpty() && it != "stored-in-keystore" } ?: fallback
|
||||
val realPassword = configPw(PasswordManager.getResticPassword(), config.resticPassword)
|
||||
val realBackendPass = configPw(PasswordManager.getBackendPass(), config.resticBackendPass)
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
defaultResticWrapper.listSnapshots(
|
||||
config.resticRepo,
|
||||
realPassword,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = realBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
)
|
||||
}
|
||||
if (result.isFailure) {
|
||||
statusText = "读取快照失败: ${result.exceptionOrNull()?.message}"
|
||||
return@launch
|
||||
@@ -114,9 +165,12 @@ fun RestoreScreen() {
|
||||
availableSnapshots = snaps
|
||||
if (snaps.size == 1) {
|
||||
loadResticSnapshot(context, snaps.first(), resticConfig!!) { pkgs, infos, status ->
|
||||
backupDir = null; selectedSnapshot = snaps.first()
|
||||
packages = pkgs; appInfos = infos
|
||||
selectedPackages = pkgs.toSet(); statusText = status
|
||||
backupDir = null
|
||||
selectedSnapshot = snaps.first()
|
||||
packages = pkgs
|
||||
appInfos = infos
|
||||
selectedPackages = pkgs.toSet()
|
||||
statusText = status
|
||||
}
|
||||
} else {
|
||||
showSnapshotPicker = true
|
||||
@@ -129,21 +183,26 @@ fun RestoreScreen() {
|
||||
}
|
||||
},
|
||||
enabled = !isRunning && resticConfig != null,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Restic 快照")
|
||||
}
|
||||
}
|
||||
|
||||
// Source info text
|
||||
val sourceText = if (backupDir != null) backupDir!!.absolutePath
|
||||
else if (selectedSnapshot != null) "restic: ${selectedSnapshot!!.time.take(19)}"
|
||||
else ""
|
||||
val sourceText =
|
||||
if (backupDir != null) {
|
||||
backupDir!!.absolutePath
|
||||
} else if (selectedSnapshot != null) {
|
||||
"restic: ${selectedSnapshot!!.time.take(19)}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
if (sourceText.isNotEmpty()) {
|
||||
Text(
|
||||
text = sourceText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -154,46 +213,54 @@ fun RestoreScreen() {
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
|
||||
// ── App list ──
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(appInfos, key = { it.packageName.value }) { app ->
|
||||
Card(
|
||||
onClick = {
|
||||
val pkg = app.packageName.value
|
||||
selectedPackages = if (pkg in selectedPackages) selectedPackages - pkg
|
||||
else selectedPackages + pkg
|
||||
selectedPackages =
|
||||
if (pkg in selectedPackages) {
|
||||
selectedPackages - pkg
|
||||
} else {
|
||||
selectedPackages + pkg
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = app.packageName.value in selectedPackages,
|
||||
onCheckedChange = { checked ->
|
||||
val pkg = app.packageName.value
|
||||
selectedPackages = if (checked) selectedPackages + pkg
|
||||
else selectedPackages - pkg
|
||||
}
|
||||
selectedPackages =
|
||||
if (checked) {
|
||||
selectedPackages + pkg
|
||||
} else {
|
||||
selectedPackages - pkg
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = app.label.ifEmpty { app.packageName.value },
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = app.packageName.value,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -221,19 +288,26 @@ fun RestoreScreen() {
|
||||
|
||||
try {
|
||||
statusText = "正在从 restic 快照恢复…"
|
||||
val restoreResult = withContext(Dispatchers.IO) {
|
||||
ResticWrapper.restore(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
snapshotId = snapshot.id,
|
||||
targetPath = staging.absolutePath,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
)
|
||||
}
|
||||
val restoreResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val rPw =
|
||||
PasswordManager.getResticPassword()?.takeIf { it != "stored-in-keystore" }
|
||||
?: config.resticPassword
|
||||
val rBpw =
|
||||
PasswordManager.getBackendPass()?.takeIf { it != "stored-in-keystore" }
|
||||
?: config.resticBackendPass
|
||||
defaultResticWrapper.restore(
|
||||
repoPath = config.resticRepo,
|
||||
password = rPw,
|
||||
snapshotId = snapshot.id,
|
||||
targetPath = staging.absolutePath,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = rBpw,
|
||||
backendShare = config.resticBackendShare,
|
||||
)
|
||||
}
|
||||
if (restoreResult.isFailure) {
|
||||
statusText = "restic 恢复失败: ${restoreResult.exceptionOrNull()?.message}"
|
||||
return@launch
|
||||
@@ -241,45 +315,54 @@ fun RestoreScreen() {
|
||||
val restoredDir = File(staging, backupPath.removePrefix("/"))
|
||||
statusText = "正在从恢复的备份安装应用…"
|
||||
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
RestoreOperation.restoreApps(
|
||||
context = context,
|
||||
backupDir = restoredDir,
|
||||
userId = config.backupUserId.toString(),
|
||||
filterPkgs = selectedPackages,
|
||||
onProgress = { progress ->
|
||||
statusText =
|
||||
"[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
|
||||
},
|
||||
)
|
||||
}
|
||||
WifiManager.restore(restoredDir)
|
||||
statusText =
|
||||
buildString {
|
||||
appendLine("恢复完成!")
|
||||
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
|
||||
append("耗时: ${result.elapsedMs / 1000}秒")
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
staging.deleteRecursively()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
} else if (backupDir != null) {
|
||||
val dir = backupDir!!
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
RestoreOperation.restoreApps(
|
||||
context = context,
|
||||
backupDir = restoredDir,
|
||||
backupDir = dir,
|
||||
userId = config.backupUserId.toString(),
|
||||
filterPkgs = selectedPackages,
|
||||
onProgress = { progress ->
|
||||
statusText = "[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
|
||||
}
|
||||
statusText =
|
||||
"[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
|
||||
},
|
||||
)
|
||||
}
|
||||
WifiManager.restore(restoredDir)
|
||||
statusText = buildString {
|
||||
WifiManager.restore(dir)
|
||||
statusText =
|
||||
buildString {
|
||||
appendLine("恢复完成!")
|
||||
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
|
||||
append("耗时: ${result.elapsedMs / 1000}秒")
|
||||
}
|
||||
} finally {
|
||||
try { staging.deleteRecursively() } catch (_: Exception) {}
|
||||
}
|
||||
} else if (backupDir != null) {
|
||||
val dir = backupDir!!
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
RestoreOperation.restoreApps(
|
||||
context = context,
|
||||
backupDir = dir,
|
||||
userId = config.backupUserId.toString(),
|
||||
filterPkgs = selectedPackages,
|
||||
onProgress = { progress ->
|
||||
statusText = "[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
|
||||
}
|
||||
)
|
||||
}
|
||||
WifiManager.restore(dir)
|
||||
statusText = buildString {
|
||||
appendLine("恢复完成!")
|
||||
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
|
||||
append("耗时: ${result.elapsedMs / 1000}秒")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
statusText = "恢复异常: ${e.message}"
|
||||
@@ -289,7 +372,7 @@ fun RestoreScreen() {
|
||||
}
|
||||
},
|
||||
enabled = !isRunning && selectedPackages.isNotEmpty() && (backupDir != null || selectedSnapshot != null),
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp)
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
) {
|
||||
if (isRunning) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||
@@ -314,59 +397,69 @@ fun RestoreScreen() {
|
||||
showSnapshotPicker = false
|
||||
scope.launch {
|
||||
loadResticSnapshot(context, snap, resticConfig!!) { pkgs, infos, status ->
|
||||
backupDir = null; selectedSnapshot = snap
|
||||
packages = pkgs; appInfos = infos
|
||||
selectedPackages = pkgs.toSet(); statusText = status
|
||||
backupDir = null
|
||||
selectedSnapshot = snap
|
||||
packages = pkgs
|
||||
appInfos = infos
|
||||
selectedPackages = pkgs.toSet()
|
||||
statusText = status
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(label) }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showSnapshotPicker = false }) { Text("取消") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sub-composables ──
|
||||
|
||||
// ── Helper functions ──
|
||||
|
||||
private suspend fun loadFromDir(
|
||||
context: android.content.Context,
|
||||
dir: File,
|
||||
onResult: (packages: List<String>, appInfos: List<AppInfo>, status: String) -> Unit
|
||||
onResult: (packages: List<String>, appInfos: List<AppInfo>, status: String) -> Unit,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val appListFile = File(dir, "appList.txt")
|
||||
val pkgs = if (appListFile.exists()) {
|
||||
appListFile.readLines()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
} else {
|
||||
dir.listFiles()
|
||||
?.filter { it.isDirectory }
|
||||
?.map { it.name }
|
||||
?: emptyList()
|
||||
}
|
||||
val pkgs =
|
||||
BackupOperation.readTextFile(appListFile)?.let { content ->
|
||||
content.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
} ?: run {
|
||||
BackupOperation.listBackupFiles(dir)
|
||||
?: emptyList()
|
||||
}
|
||||
// Filter to only apps that have actual backup data (at least one APK)
|
||||
val validPkgs =
|
||||
pkgs.filter { pkg ->
|
||||
val appDir = File(dir, pkg)
|
||||
val files = BackupOperation.listBackupFiles(appDir)
|
||||
files?.any { it.endsWith(".apk") } == true
|
||||
}
|
||||
val skipped = pkgs.size - validPkgs.size
|
||||
// Read cached labels from app_details.json (includes uninstalled apps)
|
||||
val cachedLabels = readLocalAppDetails(dir)
|
||||
val preLabeled = pkgs.map { pkg ->
|
||||
AppInfo(packageName = PackageName(pkg), label = cachedLabels[pkg] ?: "")
|
||||
}
|
||||
val preLabeled =
|
||||
validPkgs.map { pkg ->
|
||||
AppInfo(packageName = PackageName(pkg), label = cachedLabels[pkg] ?: "")
|
||||
}
|
||||
// Resolve labels for currently installed apps, keep cached labels for uninstalled
|
||||
val resolved = AppScanner.resolveLabels(context, preLabeled)
|
||||
// For apps that resolveLabels fell back to package name, restore cached label
|
||||
val infos = resolved.map { app ->
|
||||
val cachedLabel = cachedLabels[app.packageName.value]
|
||||
if (cachedLabel != null && app.label == app.packageName.value) app.copy(label = cachedLabel)
|
||||
else app
|
||||
}
|
||||
onResult(pkgs, infos, "共 ${pkgs.size} 个备份应用")
|
||||
val infos =
|
||||
resolved.map { app ->
|
||||
val cachedLabel = cachedLabels[app.packageName.value]
|
||||
if (cachedLabel != null && app.label == app.packageName.value) {
|
||||
app.copy(label = cachedLabel)
|
||||
} else {
|
||||
app
|
||||
}
|
||||
}
|
||||
val suffix = if (skipped > 0) "(${skipped}个应用备份数据缺失已自动跳过)" else ""
|
||||
onResult(validPkgs, infos, "共 ${validPkgs.size} 个备份应用$suffix")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,71 +467,126 @@ private suspend fun loadResticSnapshot(
|
||||
context: android.content.Context,
|
||||
snapshot: ResticWrapper.ResticSnapshot,
|
||||
config: BackupConfig,
|
||||
onResult: (packages: List<String>, appInfos: List<AppInfo>, status: String) -> Unit
|
||||
onResult: (packages: List<String>, appInfos: List<AppInfo>, status: String) -> Unit,
|
||||
) {
|
||||
val backupPath = snapshot.paths.firstOrNull() ?: run {
|
||||
onResult(emptyList(), emptyList(), "快照中找不到备份路径")
|
||||
return
|
||||
}
|
||||
val dumpResult = ResticWrapper.dump(
|
||||
config.resticRepo, config.resticPassword,
|
||||
snapshot.id, "$backupPath/appList.txt",
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
)
|
||||
val content = dumpResult.getOrNull()
|
||||
val backupPath =
|
||||
snapshot.paths.firstOrNull() ?: run {
|
||||
onResult(emptyList(), emptyList(), "快照中找不到备份路径")
|
||||
return
|
||||
}
|
||||
|
||||
fun rp(
|
||||
key: String?,
|
||||
fallback: String,
|
||||
) = key?.takeIf { it.isNotEmpty() && it != "stored-in-keystore" } ?: fallback
|
||||
val realPassword = rp(PasswordManager.getResticPassword(), config.resticPassword)
|
||||
val realBackendPass = rp(PasswordManager.getBackendPass(), config.resticBackendPass)
|
||||
|
||||
suspend fun tryDump(path: String) =
|
||||
defaultResticWrapper
|
||||
.dump(
|
||||
config.resticRepo,
|
||||
realPassword,
|
||||
snapshot.id,
|
||||
path,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = realBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
).getOrNull()
|
||||
// 兼容流式备份(新版:根目录,旧版:meta/)和普通备份
|
||||
val content =
|
||||
tryDump("$backupPath/appList.txt")
|
||||
?: tryDump("$backupPath/meta/appList.txt")
|
||||
if (content == null) {
|
||||
onResult(emptyList(), emptyList(), "无法从快照读取应用列表")
|
||||
return
|
||||
}
|
||||
val pkgs = content.lines()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
val pkgs =
|
||||
content
|
||||
.lines()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
|
||||
// Read cached labels from app_details.json in the snapshot
|
||||
val cachedLabels = loadResticAppDetails(config, snapshot.id, backupPath)
|
||||
val preLabeled = pkgs.map { pkg ->
|
||||
AppInfo(packageName = PackageName(pkg), label = cachedLabels[pkg] ?: "")
|
||||
}
|
||||
val preLabeled =
|
||||
pkgs.map { pkg ->
|
||||
AppInfo(packageName = PackageName(pkg), label = cachedLabels[pkg] ?: "")
|
||||
}
|
||||
val resolved = AppScanner.resolveLabels(context, preLabeled)
|
||||
val infos = resolved.map { app ->
|
||||
val cachedLabel = cachedLabels[app.packageName.value]
|
||||
if (cachedLabel != null && app.label == app.packageName.value) app.copy(label = cachedLabel)
|
||||
else app
|
||||
}
|
||||
val infos =
|
||||
resolved.map { app ->
|
||||
val cachedLabel = cachedLabels[app.packageName.value]
|
||||
if (cachedLabel != null && app.label == app.packageName.value) {
|
||||
app.copy(label = cachedLabel)
|
||||
} else {
|
||||
app
|
||||
}
|
||||
}
|
||||
onResult(pkgs, infos, "restic 快照共 ${pkgs.size} 个应用")
|
||||
}
|
||||
|
||||
/** Read app_details.json from a local backup directory and return a package→label map. */
|
||||
private suspend fun readLocalAppDetails(dir: File): Map<String, String> = withContext(Dispatchers.IO) {
|
||||
val metaFile = File(dir, "app_details.json")
|
||||
if (!metaFile.exists()) return@withContext emptyMap()
|
||||
try {
|
||||
val json = metaFile.readText()
|
||||
ResticWrapper.parseAppDetailsJson(json).mapValues { it.value.label }
|
||||
} catch (_: Exception) { emptyMap() }
|
||||
}
|
||||
private suspend fun readLocalAppDetails(dir: File): Map<String, String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val metaFile = File(dir, "app_details.json")
|
||||
val json = BackupOperation.readTextFile(metaFile) ?: return@withContext emptyMap()
|
||||
try {
|
||||
defaultResticWrapper.parseAppDetailsJson(json).mapValues { it.value.label }
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
/** Dump app_details.json from a restic snapshot and return a package→label map. */
|
||||
private suspend fun loadResticAppDetails(
|
||||
config: BackupConfig,
|
||||
snapshotId: String,
|
||||
backupPath: String
|
||||
backupPath: String,
|
||||
): Map<String, String> {
|
||||
val dumpResult = ResticWrapper.dump(
|
||||
config.resticRepo, config.resticPassword,
|
||||
snapshotId, "$backupPath/app_details.json",
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
)
|
||||
val json = dumpResult.getOrNull() ?: return emptyMap()
|
||||
fun rp2(
|
||||
key: String?,
|
||||
fallback: String,
|
||||
) = key?.takeIf { it.isNotEmpty() && it != "stored-in-keystore" } ?: fallback
|
||||
val realPassword = rp2(PasswordManager.getResticPassword(), config.resticPassword)
|
||||
val realBackendPass = rp2(PasswordManager.getBackendPass(), config.resticBackendPass)
|
||||
|
||||
suspend fun tryDump(path: String) =
|
||||
defaultResticWrapper
|
||||
.dump(
|
||||
config.resticRepo,
|
||||
realPassword,
|
||||
snapshotId,
|
||||
path,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = realBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
).getOrNull()
|
||||
val json =
|
||||
tryDump("$backupPath/app_details.json")
|
||||
?: tryDump("$backupPath/meta/app_details.json")
|
||||
?: return emptyMap()
|
||||
return try {
|
||||
ResticWrapper.parseAppDetailsJson(json).mapValues { it.value.label }
|
||||
} catch (_: Exception) { emptyMap() }
|
||||
defaultResticWrapper.parseAppDetailsJson(json).mapValues { it.value.label }
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert SAF tree URI to a filesystem path. */
|
||||
private fun resolveSafTreeUri(uri: Uri): String? {
|
||||
val docId = uri.lastPathSegment?.let { java.net.URLDecoder.decode(it, "UTF-8") } ?: return null
|
||||
val colonIdx = docId.indexOf(':')
|
||||
if (colonIdx < 0) return null
|
||||
val storageId = docId.substring(0, colonIdx)
|
||||
val relPath = docId.substring(colonIdx + 1).trim('/')
|
||||
return if (storageId.equals("primary", ignoreCase = true)) {
|
||||
"/storage/emulated/0/$relPath"
|
||||
} else {
|
||||
"/storage/$storageId/$relPath"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
|
||||
class AppResultTest :
|
||||
FunSpec({
|
||||
|
||||
context("AppResult.Success") {
|
||||
test("holds value correctly") {
|
||||
val result: AppResult<String> = AppResult.Success("hello")
|
||||
result.isSuccess shouldBe true
|
||||
result.isFailure shouldBe false
|
||||
result.getOrNull() shouldBe "hello"
|
||||
result.getOrDefault("default") shouldBe "hello"
|
||||
}
|
||||
|
||||
test("fold maps success branch") {
|
||||
val result: AppResult<Int> = AppResult.Success(42)
|
||||
val output = result.fold({ it * 2 }, { -1 })
|
||||
output shouldBe 84
|
||||
}
|
||||
|
||||
test("map transforms value") {
|
||||
val result = AppResult.Success(42)
|
||||
val mapped = result.map { it.toString() }
|
||||
mapped.shouldBeInstanceOf<AppResult.Success<String>>()
|
||||
mapped.getOrNull() shouldBe "42"
|
||||
}
|
||||
|
||||
test("getOrThrow returns value") {
|
||||
val result = AppResult.Success(99)
|
||||
result.getOrThrow() shouldBe 99
|
||||
}
|
||||
}
|
||||
|
||||
context("AppResult.Failure") {
|
||||
val error = AppError.Network("connection lost")
|
||||
|
||||
test("holds error correctly") {
|
||||
val result: AppResult<Int> = AppResult.Failure(error)
|
||||
result.isSuccess shouldBe false
|
||||
result.isFailure shouldBe true
|
||||
result.getOrNull().shouldBeNull()
|
||||
result.getOrDefault(0) shouldBe 0
|
||||
result.errorOrNull() shouldBe error
|
||||
}
|
||||
|
||||
test("fold maps failure branch") {
|
||||
val result: AppResult<Int> = AppResult.Failure(error)
|
||||
val output = result.fold({ it }, { err -> -1 })
|
||||
output shouldBe -1
|
||||
}
|
||||
|
||||
test("map passes through failure") {
|
||||
val result: AppResult<Int> = AppResult.Failure(error)
|
||||
val mapped = result.map { it * 2 }
|
||||
mapped.shouldBeInstanceOf<AppResult.Failure>()
|
||||
mapped.errorOrNull() shouldBe error
|
||||
}
|
||||
|
||||
test("getOrThrow throws") {
|
||||
val result = AppResult.Failure(error)
|
||||
shouldThrow<RuntimeException> { result.getOrThrow() }
|
||||
}
|
||||
|
||||
test("mapError transforms the error") {
|
||||
val result: AppResult<Int> = AppResult.Failure(error)
|
||||
val mapped = result.mapError { AppError.Parse("wrapped: ${it.message}") }
|
||||
mapped.shouldBeInstanceOf<AppResult.Failure>()
|
||||
(mapped.errorOrNull() as? AppError.Parse)?.let {
|
||||
it.message shouldBe "wrapped: connection lost"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context("err helper") {
|
||||
test("creates Failure") {
|
||||
val result = err<String>(AppError.Cancelled)
|
||||
result.shouldBeInstanceOf<AppResult.Failure>()
|
||||
result.errorOrNull() shouldBe AppError.Cancelled
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -4,59 +4,41 @@ import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import java.io.File
|
||||
|
||||
class BackupConfigTest : FunSpec({
|
||||
class BackupConfigTest :
|
||||
FunSpec({
|
||||
|
||||
// Helper: write config to temp file, read it back
|
||||
fun roundTrip(config: BackupConfig): BackupConfig {
|
||||
val tmp = File.createTempFile("cfg_test", ".conf")
|
||||
try {
|
||||
BackupConfig.toFile(config, tmp)
|
||||
return BackupConfig.fromFile(tmp)
|
||||
} finally {
|
||||
tmp.delete()
|
||||
// Helper: write config to temp file, read it back
|
||||
fun roundTrip(config: BackupConfig): BackupConfig {
|
||||
val tmp = File.createTempFile("cfg_test", ".conf")
|
||||
try {
|
||||
BackupConfig.toFile(config, tmp)
|
||||
return BackupConfig.fromFile(tmp)
|
||||
} finally {
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("plain password survives round trip") {
|
||||
val c = BackupConfig(resticPassword = "simple123")
|
||||
roundTrip(c).resticPassword shouldBe "simple123"
|
||||
}
|
||||
test("password is stored as placeholder (actual password in PasswordManager)") {
|
||||
val c = BackupConfig(resticPassword = "simple123")
|
||||
// Password is no longer in config file; toFile writes "stored-in-keystore"
|
||||
roundTrip(c).resticPassword shouldBe ""
|
||||
}
|
||||
|
||||
test("password with double-quote survives round trip") {
|
||||
val c = BackupConfig(resticPassword = "pa\"ss\"word")
|
||||
roundTrip(c).resticPassword shouldBe "pa\"ss\"word"
|
||||
}
|
||||
test("backend pass is stored as placeholder (actual pass in PasswordManager)") {
|
||||
val c = BackupConfig(resticBackendPass = "secret")
|
||||
roundTrip(c).resticBackendPass shouldBe ""
|
||||
}
|
||||
|
||||
test("password with backslash survives round trip") {
|
||||
val c = BackupConfig(resticPassword = "p\\a\\ss")
|
||||
roundTrip(c).resticPassword shouldBe "p\\a\\ss"
|
||||
}
|
||||
test("output path with spaces survives round trip") {
|
||||
val c = BackupConfig(outputPath = "/sdcard/my backups/")
|
||||
roundTrip(c).outputPath shouldBe "/sdcard/my backups/"
|
||||
}
|
||||
|
||||
test("password with leading and trailing spaces survives round trip") {
|
||||
val c = BackupConfig(resticPassword = " sp ace ")
|
||||
roundTrip(c).resticPassword shouldBe " sp ace "
|
||||
}
|
||||
|
||||
test("password with special shell characters survives round trip") {
|
||||
val c = BackupConfig(resticPassword = "p@\$s#w!ord&")
|
||||
roundTrip(c).resticPassword shouldBe "p@\$s#w!ord&"
|
||||
}
|
||||
|
||||
test("restic_backend_pass with quote and backslash survives round trip") {
|
||||
val c = BackupConfig(resticBackendPass = "a\\\"b")
|
||||
roundTrip(c).resticBackendPass shouldBe "a\\\"b"
|
||||
}
|
||||
|
||||
test("output path with spaces survives round trip") {
|
||||
val c = BackupConfig(outputPath = "/sdcard/my backups/")
|
||||
roundTrip(c).outputPath shouldBe "/sdcard/my backups/"
|
||||
}
|
||||
|
||||
test("non-restic fields are unaffected") {
|
||||
val c = BackupConfig(backupMode = 1, backupWifi = 0, compressionMethod = "zstd")
|
||||
val out = roundTrip(c)
|
||||
out.backupMode shouldBe 1
|
||||
out.backupWifi shouldBe 0
|
||||
out.compressionMethod shouldBe "zstd"
|
||||
}
|
||||
})
|
||||
test("non-restic fields are unaffected") {
|
||||
val c = BackupConfig(backupMode = 1, backupWifi = 0, compressionMethod = "zstd")
|
||||
val out = roundTrip(c)
|
||||
out.backupMode shouldBe 1
|
||||
out.backupWifi shouldBe 0
|
||||
out.compressionMethod shouldBe "zstd"
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
|
||||
class PackageNameTest :
|
||||
FunSpec({
|
||||
|
||||
context("PackageName constructor validation") {
|
||||
test("accepts valid package names") {
|
||||
PackageName("com.example.app").value shouldBe "com.example.app"
|
||||
PackageName("com.google.android.gms").value shouldBe "com.google.android.gms"
|
||||
PackageName("a.b").value shouldBe "a.b"
|
||||
PackageName("com.example.app_v2.test").value shouldBe "com.example.app_v2.test"
|
||||
PackageName("org.koin.android").value shouldBe "org.koin.android"
|
||||
}
|
||||
|
||||
test("rejects blank package names") {
|
||||
shouldThrow<IllegalArgumentException> { PackageName("") }
|
||||
shouldThrow<IllegalArgumentException> { PackageName(" ") }
|
||||
}
|
||||
|
||||
test("rejects package names without dots") {
|
||||
shouldThrow<IllegalArgumentException> { PackageName("simple") }
|
||||
shouldThrow<IllegalArgumentException> { PackageName("no_dot_at_all") }
|
||||
}
|
||||
|
||||
test("rejects package names with invalid characters") {
|
||||
shouldThrow<IllegalArgumentException> { PackageName("com.example .app") }
|
||||
shouldThrow<IllegalArgumentException> { PackageName("com.example/app") }
|
||||
shouldThrow<IllegalArgumentException> { PackageName("com.example\napp") }
|
||||
}
|
||||
|
||||
test("rejects package names starting with dot") {
|
||||
shouldThrow<IllegalArgumentException> { PackageName(".com.example") }
|
||||
}
|
||||
|
||||
test("rejects package names ending with dot") {
|
||||
shouldThrow<IllegalArgumentException> { PackageName("com.example.") }
|
||||
}
|
||||
}
|
||||
|
||||
context("PackageName.safe") {
|
||||
test("returns PackageName for valid input") {
|
||||
PackageName.safe("com.example.app").shouldNotBeNull()
|
||||
PackageName.safe("a.b").shouldNotBeNull()
|
||||
}
|
||||
|
||||
test("returns null for invalid input instead of throwing") {
|
||||
PackageName.safe("").shouldBeNull()
|
||||
PackageName.safe("no_dots").shouldBeNull()
|
||||
PackageName.safe("with space").shouldBeNull()
|
||||
PackageName.safe("with/slash").shouldBeNull()
|
||||
}
|
||||
}
|
||||
|
||||
context("PackageName equality and toString") {
|
||||
test("value equality works") {
|
||||
PackageName("com.example.app") shouldBe PackageName("com.example.app")
|
||||
}
|
||||
|
||||
test("toString returns the package name") {
|
||||
PackageName("com.example.app").toString() shouldBe "com.example.app"
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user