Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cffa9a2b8a | ||
|
|
834f515e01 | ||
|
|
949d13f1ea | ||
|
|
d701951338 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,5 +21,5 @@ release.keystore
|
||||
memory:*
|
||||
|
||||
# Restic test repository (contains encryption keys)
|
||||
test/
|
||||
/test/
|
||||
kmboxnet
|
||||
|
||||
@@ -104,6 +104,7 @@ restic 通过 REST HTTP API 与本地桥通信,桥接器将请求翻译为 SMB
|
||||
|
||||
| 版本 | 更新内容 |
|
||||
|------|---------|
|
||||
| v1.14 | 修复 shell 转义/管道死锁/配置序列化缺陷,新增配置导出与 BackupConfig 单元测试 |
|
||||
| v1.13 | Compose Material 3 UI 重构、Unlock 支持、ResticBinary 启动初始化、修复 500 错误和刷新竞态 |
|
||||
| v1.12 | 引擎 + Compose Material 3 UI 重构 |
|
||||
| v1.11 | 构建系统改进、LSP 支持 |
|
||||
|
||||
@@ -25,7 +25,7 @@ android {
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 14
|
||||
versionName "1.13"
|
||||
versionName "1.14"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
|
||||
@@ -76,9 +76,41 @@ data class BackupConfig(
|
||||
val resticBackendDomain: String = "" // SMB domain (optional, for NTLM)
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Unescape a quoted config value. Reverses [escapeValue]: turns \\ and \"
|
||||
* back into \ and ". Applied only to values that were stored inside quotes.
|
||||
*/
|
||||
private fun unescapeValue(s: String): String {
|
||||
if (s.indexOf('\\') < 0) return s
|
||||
val sb = StringBuilder(s.length)
|
||||
var i = 0
|
||||
while (i < s.length) {
|
||||
val c = s[i]
|
||||
if (c == '\\' && i + 1 < s.length) {
|
||||
sb.append(s[i + 1]); i += 2
|
||||
} else {
|
||||
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("\"", "\\\"")
|
||||
|
||||
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 props = mutableMapOf<String, String>()
|
||||
file.forEachLine { line ->
|
||||
val trimmed = line.trim()
|
||||
@@ -86,8 +118,21 @@ data class BackupConfig(
|
||||
val eq = trimmed.indexOf('=')
|
||||
if (eq < 0) return@forEachLine
|
||||
val key = trimmed.substring(0, eq).trim()
|
||||
val value = trimmed.substring(eq + 1).trim().removeSurrounding("\"")
|
||||
props[key] = value
|
||||
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))
|
||||
} else {
|
||||
// Legacy/unquoted value — fall back to trimmed form.
|
||||
unescapeValue(v.trim().removeSurrounding("\""))
|
||||
}
|
||||
} else {
|
||||
rawValue.trim().removeSurrounding("\"")
|
||||
}
|
||||
}
|
||||
|
||||
fun int(key: String, default: Int = 0) = props[key]?.toIntOrNull() ?: default
|
||||
@@ -146,11 +191,11 @@ data class BackupConfig(
|
||||
appendLine("background_execution=${config.backgroundExecution}")
|
||||
appendLine("setDisplayPowerMode=${config.setDisplayPowerMode}")
|
||||
appendLine("Shell_LANG=${config.shellLang}")
|
||||
appendLine("Output_path=\"${config.outputPath}\"")
|
||||
appendLine("list_location=\"${config.listLocation}\"")
|
||||
appendLine("Output_path=\"${escapeValue(config.outputPath)}\"")
|
||||
appendLine("list_location=\"${escapeValue(config.listLocation)}\"")
|
||||
appendLine("update=${config.update}")
|
||||
appendLine("cdn=${config.cdn}")
|
||||
appendLine("mount_point=\"${config.mountPoint}\"")
|
||||
appendLine("mount_point=\"${escapeValue(config.mountPoint)}\"")
|
||||
appendLine("user=${config.user}")
|
||||
appendLine("Backup_Mode=${config.backupMode}")
|
||||
appendLine("Backup_user_data=${config.backupUserData}")
|
||||
@@ -177,14 +222,14 @@ data class BackupConfig(
|
||||
appendLine("rgb_c=${config.rgbC}")
|
||||
appendLine("backup_wifi=${config.backupWifi}")
|
||||
appendLine("restic_enabled=${config.resticEnabled}")
|
||||
appendLine("restic_repo=\"${config.resticRepo}\"")
|
||||
appendLine("restic_password=\"${config.resticPassword}\"")
|
||||
appendLine("restic_repo=\"${escapeValue(config.resticRepo)}\"")
|
||||
appendLine("restic_password=\"${escapeValue(config.resticPassword)}\"")
|
||||
appendLine("restic_backend=${config.resticBackend}")
|
||||
appendLine("restic_backend_url=\"${config.resticBackendUrl}\"")
|
||||
appendLine("restic_backend_user=\"${config.resticBackendUser}\"")
|
||||
appendLine("restic_backend_pass=\"${config.resticBackendPass}\"")
|
||||
appendLine("restic_backend_share=\"${config.resticBackendShare}\"")
|
||||
appendLine("restic_backend_domain=\"${config.resticBackendDomain}\"")
|
||||
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
|
||||
|
||||
@@ -66,11 +66,23 @@ class ResticCommandRunner {
|
||||
pb.redirectErrorStream(false)
|
||||
val 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 stdout = process.inputStream.bufferedReader().use(BufferedReader::readText)
|
||||
val stderrBytes = process.errorStream.use { it.readAllBytesCompat() }
|
||||
val exitCode = try {
|
||||
process.waitForCompat()
|
||||
} catch (_: Exception) { -1 }
|
||||
try { stderrThread.join(1_000) } catch (_: InterruptedException) {}
|
||||
val stderrText = stderrBytes.decodeToString()
|
||||
Log.i(TAG, "runRestic exitCode=$exitCode stdout_len=${stdout.length}")
|
||||
if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText.trim()}")
|
||||
|
||||
@@ -344,6 +344,14 @@ object RestoreOperation {
|
||||
val ssaidValue = ssaidFile.readText().trim()
|
||||
if (ssaidValue.isBlank()) 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,
|
||||
// not the double-quoted sed string).
|
||||
if (!ssaidValue.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
Log.w(TAG, "restoreSsaid: ssaid value is not hex, skipping XML edit for $packageName")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the app's UID
|
||||
val uidResult = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep 'userId=' | head -1")
|
||||
val uid = uidResult.output
|
||||
@@ -371,7 +379,8 @@ object RestoreOperation {
|
||||
// Generate a UUID for the new entry
|
||||
val uuidResult = RootShell.exec("cat /proc/sys/kernel/random/uuid 2>/dev/null")
|
||||
val id = uuidResult.output.trim()
|
||||
if (id.length != 36) { // UUID format check
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -64,6 +64,9 @@ object RootShell {
|
||||
// Shell already created (e.g. from Application superclass or prior session).
|
||||
// The default builder is already in effect — our custom config is ignored
|
||||
// but the shell is still functional.
|
||||
} catch (e: Exception) {
|
||||
// Some ROMs throw other exceptions during root init; don't crash startup.
|
||||
Log.w(TAG, "configure: failed to set default builder", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.example.androidbackupgui.ui
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -93,6 +96,8 @@ fun ConfigScreen(
|
||||
is OperationEvent.PruneStarted -> "正在清理快照…"
|
||||
is OperationEvent.PruneCompleted -> "清理完成"
|
||||
is OperationEvent.PruneFailed -> "清理失败"
|
||||
is OperationEvent.ConfigExported -> "配置已导出"
|
||||
is OperationEvent.ConfigExportFailed -> "配置导出失败"
|
||||
else -> null
|
||||
}
|
||||
if (msg != null) {
|
||||
@@ -103,6 +108,13 @@ 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)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -382,6 +394,23 @@ fun ConfigScreen(
|
||||
Text("保存配置")
|
||||
}
|
||||
|
||||
// ── Export config button ──
|
||||
OutlinedButton(
|
||||
onClick = { exportLauncher.launch("backup_settings.conf") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ sealed interface OperationEvent {
|
||||
data object PruneStarted : OperationEvent
|
||||
data object PruneFailed : OperationEvent
|
||||
data object PruneCompleted : OperationEvent
|
||||
data object ConfigExported : OperationEvent
|
||||
data object ConfigExportFailed : OperationEvent
|
||||
}
|
||||
|
||||
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
|
||||
@@ -170,6 +172,45 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the current saved config to a user-selected destination [Uri] (SAF).
|
||||
* Writes the same on-disk config format, including the plaintext restic password,
|
||||
* so the warning is surfaced in the UI before export.
|
||||
*/
|
||||
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() }
|
||||
}
|
||||
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 = "配置已导出")) }
|
||||
} else {
|
||||
_operationEvents.emit(OperationEvent.ConfigExportFailed)
|
||||
_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>()
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import java.io.File
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
test("plain password survives round trip") {
|
||||
val c = BackupConfig(resticPassword = "simple123")
|
||||
roundTrip(c).resticPassword shouldBe "simple123"
|
||||
}
|
||||
|
||||
test("password with double-quote survives round trip") {
|
||||
val c = BackupConfig(resticPassword = "pa\"ss\"word")
|
||||
roundTrip(c).resticPassword shouldBe "pa\"ss\"word"
|
||||
}
|
||||
|
||||
test("password with backslash survives round trip") {
|
||||
val c = BackupConfig(resticPassword = "p\\a\\ss")
|
||||
roundTrip(c).resticPassword shouldBe "p\\a\\ss"
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user