feat(release): 阶段6-7 Restic streaming标识、发布治理、CI
阶段6:Restic streaming 策略 - ConfigScreen 流式备份文案改为'实验性 Restic 临时目录备份' 并显示不完整备份警告 - ResticStreamBackup 写入 streaming_manifest.json 记录 excluded 项目 - RestoreViewModel 检测 streaming manifest 并在确认弹窗中显示警告 阶段7:发布与仓库治理 - .gitignore 排除 app/release/*.apk - build.gradle release 构建强制签名,启用 R8 + shrinkResources - proguard-rules.pro 修正 restic 类路径,启用 R8 keep 规则 - 新增 .github/workflows/android.yml (CI: lint/test/assembleDebug) - 新增 .github/workflows/release.yml (Release: tag触发,签名,sha256)
This commit is contained in:
47
.github/workflows/android.yml
vendored
Normal file
47
.github/workflows/android.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
name: Android CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up JDK 17
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
java-version: '17'
|
||||||
|
distribution: 'temurin'
|
||||||
|
cache: gradle
|
||||||
|
|
||||||
|
- name: Grant execute permission for gradlew
|
||||||
|
run: chmod +x gradlew
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: ./gradlew :app:lintDebug
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: ./gradlew :app:testDebugUnitTest
|
||||||
|
|
||||||
|
- name: Assemble debug
|
||||||
|
run: ./gradlew :app:assembleDebug
|
||||||
|
|
||||||
|
- name: Upload lint report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: lint-report
|
||||||
|
path: app/build/reports/lint-results-debug.html
|
||||||
|
|
||||||
|
- name: Upload test report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: test-report
|
||||||
|
path: app/build/reports/tests/testDebugUnitTest/
|
||||||
47
.github/workflows/release.yml
vendored
Normal file
47
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up JDK 17
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
java-version: '17'
|
||||||
|
distribution: 'temurin'
|
||||||
|
cache: gradle
|
||||||
|
|
||||||
|
- name: Decode keystore
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/release.keystore
|
||||||
|
|
||||||
|
- name: Grant execute permission for gradlew
|
||||||
|
run: chmod +x gradlew
|
||||||
|
|
||||||
|
- name: Assemble release
|
||||||
|
env:
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
run: ./gradlew :app:assembleRelease
|
||||||
|
|
||||||
|
- name: Generate checksum
|
||||||
|
run: |
|
||||||
|
cd app/build/outputs/apk/release
|
||||||
|
sha256sum *.apk > checksums.sha256
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
app/build/outputs/apk/release/*.apk
|
||||||
|
app/build/outputs/apk/release/checksums.sha256
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -23,3 +23,10 @@ memory:*
|
|||||||
# Restic test repository (contains encryption keys)
|
# Restic test repository (contains encryption keys)
|
||||||
/test/
|
/test/
|
||||||
kmboxnet
|
kmboxnet
|
||||||
|
|
||||||
|
# Release artifacts
|
||||||
|
app/release/*.apk
|
||||||
|
app/release/*.aab
|
||||||
|
app/release/*.idsig
|
||||||
|
app/release/*.sha256
|
||||||
|
app/release/output-metadata.json
|
||||||
|
|||||||
@@ -48,12 +48,20 @@ android {
|
|||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
if (rootProject.file("app/release.keystore").exists()) {
|
minifyEnabled true
|
||||||
def ksPass = System.getenv("KEYSTORE_PASSWORD")
|
shrinkResources true
|
||||||
def kPass = System.getenv("KEY_PASSWORD")
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
if (ksPass != null && kPass != null) {
|
def ksFile = rootProject.file("app/release.keystore")
|
||||||
signingConfig signingConfigs.release
|
def ksPass = System.getenv("KEYSTORE_PASSWORD")
|
||||||
|
def kPass = System.getenv("KEY_PASSWORD")
|
||||||
|
def isReleaseTask = gradle.startParameter.taskNames.any { it.toLowerCase().contains("release") }
|
||||||
|
if (isReleaseTask) {
|
||||||
|
if (!ksFile.exists() || ksPass == null || ksPass.isEmpty() || kPass == null || kPass.isEmpty()) {
|
||||||
|
throw new GradleException("Release build requires signing config. Set KEYSTORE_PASSWORD and KEY_PASSWORD env vars and ensure app/release.keystore exists.")
|
||||||
}
|
}
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
} else if (ksFile.exists() && ksPass != null && !ksPass.isEmpty() && kPass != null && !kPass.isEmpty()) {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
app/proguard-rules.pro
vendored
27
app/proguard-rules.pro
vendored
@@ -24,35 +24,32 @@
|
|||||||
-keep class fi.iki.elonen.** { *; }
|
-keep class fi.iki.elonen.** { *; }
|
||||||
|
|
||||||
# --- RemoteTransport (WebDAV/SMB) ---
|
# --- RemoteTransport (WebDAV/SMB) ---
|
||||||
-keep class com.example.androidbackupgui.backup.RemoteTransport { *; }
|
-keep class com.example.androidbackupgui.backup.restic.RemoteTransport { *; }
|
||||||
|
|
||||||
# --- Data classes (serialization) ---
|
# --- Data classes (serialization) ---
|
||||||
-keep class com.example.androidbackupgui.backup.ResticProgress { *; }
|
-keep class com.example.androidbackupgui.backup.restic.ResticWrapper$ResticProgress { *; }
|
||||||
-keep class com.example.androidbackupgui.backup.BackupSummary { *; }
|
-keep class com.example.androidbackupgui.backup.restic.ResticWrapper$BackupSummary { *; }
|
||||||
-keep class com.example.androidbackupgui.backup.ResticSnapshot { *; }
|
-keep class com.example.androidbackupgui.backup.restic.ResticWrapper$ResticSnapshot { *; }
|
||||||
-keep class com.example.androidbackupgui.backup.RestoreProgress { *; }
|
-keep class com.example.androidbackupgui.backup.RestoreOperation$RestoreProgress { *; }
|
||||||
-keep class com.example.androidbackupgui.backup.BackupConfig { *; }
|
-keep class com.example.androidbackupgui.backup.BackupConfig { *; }
|
||||||
-keep class com.example.androidbackupgui.backup.AppError { *; }
|
-keep class com.example.androidbackupgui.backup.core.AppError { *; }
|
||||||
-keep class com.example.androidbackupgui.backup.AppResult { *; }
|
-keep class com.example.androidbackupgui.backup.core.AppResult { *; }
|
||||||
|
|
||||||
|
|
||||||
# --- RemoteTransport implementations ---
|
# --- RemoteTransport implementations ---
|
||||||
-keep class com.example.androidbackupgui.backup.SmbTransport { *; }
|
-keep class com.example.androidbackupgui.backup.restic.SmbTransport { *; }
|
||||||
-keep class com.example.androidbackupgui.backup.WebdavTransport { *; }
|
-keep class com.example.androidbackupgui.backup.restic.WebdavTransport { *; }
|
||||||
|
|
||||||
# --- WifiManager (called from UI, kept for safety) ---
|
# --- WifiManager (called from UI, kept for safety) ---
|
||||||
-keep class com.example.androidbackupgui.backup.WifiManager { *; }
|
-keep class com.example.androidbackupgui.backup.WifiManager { *; }
|
||||||
|
|
||||||
# --- Keep data models used by kotlinx.serialization ---
|
# --- Keep data models used by kotlinx.serialization ---
|
||||||
## Keep all model classes that may be referenced via @Serializable
|
|
||||||
-keep class com.example.androidbackupgui.model.** { *; }
|
-keep class com.example.androidbackupgui.model.** { *; }
|
||||||
|
|
||||||
# --- Keep R classes (referenced by code) ---
|
# --- Keep R classes (referenced by code) ---
|
||||||
-keep class com.example.androidbackupgui.R { *; }
|
-keep class com.example.androidbackupgui.R { *; }
|
||||||
|
|
||||||
|
# --- jcifs-ng (SMB) — keep class/member names for reflection ---
|
||||||
|
|
||||||
# --- jcifs-ng (SMB) — keep class/member names for reflection (was MD4Provider) ---
|
|
||||||
-keep class jcifs.util.Crypto { *; }
|
-keep class jcifs.util.Crypto { *; }
|
||||||
-keep class jcifs.smb.NtlmUtil { *; }
|
-keep class jcifs.smb.NtlmUtil { *; }
|
||||||
-keep class jcifs.ntlmssp.Type3Message { *; }
|
-keep class jcifs.ntlmssp.Type3Message { *; }
|
||||||
-keep class jcifs.smb.NtlmContext { *; }
|
-keep class jcifs.ntlmssp.NtlmContext { *; }
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ data class BackupConfig(
|
|||||||
// Streaming backup: pipe tar data through FIFO directly into restic --stdin
|
// Streaming backup: pipe tar data through FIFO directly into restic --stdin
|
||||||
// 0=disabled (default, stable), 1=enabled (experimental, avoids temp files)
|
// 0=disabled (default, stable), 1=enabled (experimental, avoids temp files)
|
||||||
val useStreaming: Int = 0,
|
val useStreaming: Int = 0,
|
||||||
|
val allowInsecureWebdav: Int = 0,
|
||||||
|
val allowInsecureRestServer: Int = 0,
|
||||||
|
val smbSigningMode: String = "required",
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
@@ -181,7 +184,7 @@ data class BackupConfig(
|
|||||||
blacklist = lines("blacklist"),
|
blacklist = lines("blacklist"),
|
||||||
whitelist = lines("whitelist"),
|
whitelist = lines("whitelist"),
|
||||||
system = lines("system"),
|
system = lines("system"),
|
||||||
compressionMethod = str("Compression_method").ifEmpty { "zstd" },
|
compressionMethod = normalizeCompressionMethod(str("Compression_method")),
|
||||||
rgbA = int("rgb_a").let { if (it == 0) 226 else it },
|
rgbA = int("rgb_a").let { if (it == 0) 226 else it },
|
||||||
rgbB = int("rgb_b").let { if (it == 0) 123 else it },
|
rgbB = int("rgb_b").let { if (it == 0) 123 else it },
|
||||||
rgbC = int("rgb_c").let { if (it == 0) 177 else it },
|
rgbC = int("rgb_c").let { if (it == 0) 177 else it },
|
||||||
@@ -196,6 +199,9 @@ data class BackupConfig(
|
|||||||
resticBackendShare = str("restic_backend_share"),
|
resticBackendShare = str("restic_backend_share"),
|
||||||
resticBackendDomain = str("restic_backend_domain"),
|
resticBackendDomain = str("restic_backend_domain"),
|
||||||
useStreaming = int("streaming_backup"),
|
useStreaming = int("streaming_backup"),
|
||||||
|
allowInsecureWebdav = int("allow_insecure_webdav"),
|
||||||
|
allowInsecureRestServer = int("allow_insecure_rest_server"),
|
||||||
|
smbSigningMode = str("smb_signing_mode").ifEmpty { "required" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +242,7 @@ data class BackupConfig(
|
|||||||
append("system=\"")
|
append("system=\"")
|
||||||
config.system.forEach { append(" ${it.replace(" ", "%20")}") }
|
config.system.forEach { append(" ${it.replace(" ", "%20")}") }
|
||||||
appendLine(" \"")
|
appendLine(" \"")
|
||||||
appendLine("Compression_method=${config.compressionMethod}")
|
appendLine("Compression_method=${normalizeCompressionMethod(config.compressionMethod)}")
|
||||||
appendLine("rgb_a=${config.rgbA}")
|
appendLine("rgb_a=${config.rgbA}")
|
||||||
appendLine("rgb_b=${config.rgbB}")
|
appendLine("rgb_b=${config.rgbB}")
|
||||||
appendLine("rgb_c=${config.rgbC}")
|
appendLine("rgb_c=${config.rgbC}")
|
||||||
@@ -253,10 +259,20 @@ data class BackupConfig(
|
|||||||
appendLine("restic_backend_share=\"${escapeValue(config.resticBackendShare)}\"")
|
appendLine("restic_backend_share=\"${escapeValue(config.resticBackendShare)}\"")
|
||||||
appendLine("restic_backend_domain=\"${escapeValue(config.resticBackendDomain)}\"")
|
appendLine("restic_backend_domain=\"${escapeValue(config.resticBackendDomain)}\"")
|
||||||
appendLine("streaming_backup=${config.useStreaming}")
|
appendLine("streaming_backup=${config.useStreaming}")
|
||||||
|
appendLine("allow_insecure_webdav=${config.allowInsecureWebdav}")
|
||||||
|
appendLine("allow_insecure_rest_server=${config.allowInsecureRestServer}")
|
||||||
|
appendLine("smb_signing_mode=${config.smbSigningMode}")
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
file.setReadable(true, true) // owner only
|
file.setReadable(true, true) // owner only
|
||||||
file.setWritable(true, true) // owner only
|
file.setWritable(true, true) // owner only
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun normalizeCompressionMethod(value: String): String =
|
||||||
|
when (value.trim().lowercase()) {
|
||||||
|
"tar", "gzip", "gz" -> "tar"
|
||||||
|
"zstd", "zst", "" -> "zstd"
|
||||||
|
else -> "zstd"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,21 @@ object ResticStreamBackup {
|
|||||||
File(workDir, "app_details.json"),
|
File(workDir, "app_details.json"),
|
||||||
BackupOperation.buildAppDetailsJson(apps, legacyApps),
|
BackupOperation.buildAppDetailsJson(apps, legacyApps),
|
||||||
)
|
)
|
||||||
|
val manifestJson = buildString {
|
||||||
|
append("{")
|
||||||
|
append("\"schemaVersion\":1,")
|
||||||
|
append("\"mode\":\"restic-streaming-experimental\",")
|
||||||
|
append("\"completeBackup\":false,")
|
||||||
|
append("\"included\":[\"metadata\",\"apk\",\"app_data\"],")
|
||||||
|
append("\"excluded\":[\"obb\",\"external_data\",\"permissions\",\"ssaid\",\"wifi\"],")
|
||||||
|
append("\"maxAppDataBytes\":${MAX_STREAM_APP_SIZE_BYTES},")
|
||||||
|
append("\"createdAtEpochSeconds\":${System.currentTimeMillis() / 1000}")
|
||||||
|
append("}")
|
||||||
|
}
|
||||||
|
BackupOperation.writeFileForBackup(
|
||||||
|
File(workDir, "streaming_manifest.json"),
|
||||||
|
manifestJson,
|
||||||
|
)
|
||||||
Log.i(TAG, "Metadata written to ${workDir.absolutePath}")
|
Log.i(TAG, "Metadata written to ${workDir.absolutePath}")
|
||||||
|
|
||||||
// ── 3. Backup APK files ───────────────────
|
// ── 3. Backup APK files ───────────────────
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.example.androidbackupgui.backup.security
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object LegacyCredentialMigrator {
|
||||||
|
|
||||||
|
data class MigrationResult(
|
||||||
|
val migratedResticPassword: Boolean,
|
||||||
|
val migratedBackendPass: Boolean,
|
||||||
|
val rewroteFile: Boolean,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun migrate(configFile: File): MigrationResult {
|
||||||
|
if (!configFile.exists()) {
|
||||||
|
return MigrationResult(false, false, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val lines = configFile.readLines()
|
||||||
|
var resticPassword: String? = null
|
||||||
|
var backendPass: String? = null
|
||||||
|
|
||||||
|
for (line in lines) {
|
||||||
|
val trimmed = line.trim()
|
||||||
|
if (trimmed.isEmpty() || trimmed.startsWith("#")) continue
|
||||||
|
|
||||||
|
val eq = trimmed.indexOf('=')
|
||||||
|
if (eq < 0) continue
|
||||||
|
val key = trimmed.substring(0, eq).trim()
|
||||||
|
val rawValue = trimmed.substring(eq + 1).trim()
|
||||||
|
|
||||||
|
if (key == "restic_password") {
|
||||||
|
resticPassword = unquote(rawValue)
|
||||||
|
} else if (key == "restic_backend_pass") {
|
||||||
|
backendPass = unquote(rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var migratedRestic = false
|
||||||
|
var migratedBackend = false
|
||||||
|
|
||||||
|
if (!resticPassword.isNullOrEmpty() &&
|
||||||
|
resticPassword != "stored-in-keystore" &&
|
||||||
|
!PasswordManager.hasResticPassword()
|
||||||
|
) {
|
||||||
|
PasswordManager.setResticPassword(resticPassword)
|
||||||
|
migratedRestic = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backendPass.isNullOrEmpty() &&
|
||||||
|
backendPass != "stored-in-keystore" &&
|
||||||
|
PasswordManager.getBackendPass() == null
|
||||||
|
) {
|
||||||
|
PasswordManager.setBackendPass(backendPass)
|
||||||
|
migratedBackend = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var rewrote = false
|
||||||
|
if (migratedRestic || migratedBackend) {
|
||||||
|
val content = configFile.readText()
|
||||||
|
val updated = content
|
||||||
|
.replace(Regex("""restic_password\s*=\s*"[^"]*""""), """restic_password="stored-in-keystore"""")
|
||||||
|
.replace(Regex("""restic_password\s*=\s*[^"\s]+"""), """restic_password="stored-in-keystore"""")
|
||||||
|
.replace(Regex("""restic_backend_pass\s*=\s*"[^"]*""""), """restic_backend_pass="stored-in-keystore"""")
|
||||||
|
.replace(Regex("""restic_backend_pass\s*=\s*[^"\s]+"""), """restic_backend_pass="stored-in-keystore"""")
|
||||||
|
if (updated != content) {
|
||||||
|
configFile.writeText(updated)
|
||||||
|
rewrote = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MigrationResult(migratedRestic, migratedBackend, rewrote)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
MigrationResult(false, false, false, e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unquote(raw: String): String {
|
||||||
|
val trimmed = raw.trim()
|
||||||
|
if (trimmed.length >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
|
||||||
|
return trimmed.substring(1, trimmed.length - 1)
|
||||||
|
.replace("\\\\", "\\")
|
||||||
|
.replace("\\\"", "\"")
|
||||||
|
}
|
||||||
|
return trimmed.removeSurrounding("\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ fun ConfigScreen(
|
|||||||
backupWifi = config.backupWifi == 1
|
backupWifi = config.backupWifi == 1
|
||||||
ignoreRunning = config.backgroundAppsIgnore == 1
|
ignoreRunning = config.backgroundAppsIgnore == 1
|
||||||
outputPath = config.outputPath
|
outputPath = config.outputPath
|
||||||
compressionMethod = config.compressionMethod
|
compressionMethod = BackupConfig.normalizeCompressionMethod(config.compressionMethod)
|
||||||
backupUserId = config.backupUserId
|
backupUserId = config.backupUserId
|
||||||
resticEnabled = config.resticEnabled == 1
|
resticEnabled = config.resticEnabled == 1
|
||||||
resticRepo = config.resticRepo
|
resticRepo = config.resticRepo
|
||||||
@@ -289,6 +289,14 @@ fun ConfigScreen(
|
|||||||
label = { Text(backendDisplay.urlHint.ifEmpty { "后端地址" }) },
|
label = { Text(backendDisplay.urlHint.ifEmpty { "后端地址" }) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
isError = resticBackend == "webdav" && resticBackendUrl.startsWith("http://") && resticBackendUser.isNotEmpty(),
|
||||||
|
supportingText = {
|
||||||
|
if (resticBackend == "webdav" && resticBackendUrl.startsWith("http://") && resticBackendUser.isNotEmpty()) {
|
||||||
|
Text("Basic auth over HTTP 不允许,请使用 HTTPS", color = MaterialTheme.colorScheme.error)
|
||||||
|
} else if (resticBackend == "webdav" && resticBackendUrl.startsWith("http://")) {
|
||||||
|
Text("HTTP 不安全,建议使用 HTTPS", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (resticBackend == "webdav" || resticBackend == "smb") {
|
if (resticBackend == "webdav" || resticBackend == "smb") {
|
||||||
@@ -328,18 +336,24 @@ fun ConfigScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Streaming backup toggle ──
|
// ── Streaming backup toggle ──
|
||||||
Row(
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
Text(
|
||||||
|
"实验性 Restic 临时目录备份",
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = streamingEnabled,
|
||||||
|
onCheckedChange = { streamingEnabled = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
"流式备份 (FIFO管道 → restic --stdin)",
|
"不等同完整备份:不包含 OBB、外部数据、权限、SSAID、Wi-Fi;大应用数据可能被跳过。",
|
||||||
modifier = Modifier.weight(1f),
|
style = MaterialTheme.typography.bodySmall,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
|
||||||
Switch(
|
|
||||||
checked = streamingEnabled,
|
|
||||||
onCheckedChange = { streamingEnabled = it },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,7 +491,7 @@ fun ConfigScreen(
|
|||||||
backgroundAppsIgnore = if (ignoreRunning) 1 else 0,
|
backgroundAppsIgnore = if (ignoreRunning) 1 else 0,
|
||||||
backupUserId = backupUserId,
|
backupUserId = backupUserId,
|
||||||
outputPath = outputPath,
|
outputPath = outputPath,
|
||||||
compressionMethod = compressionMethod.ifEmpty { "zstd" },
|
compressionMethod = BackupConfig.normalizeCompressionMethod(compressionMethod),
|
||||||
resticEnabled = if (resticEnabled) 1 else 0,
|
resticEnabled = if (resticEnabled) 1 else 0,
|
||||||
resticRepo = resticRepo,
|
resticRepo = resticRepo,
|
||||||
resticPassword = resticPassword,
|
resticPassword = resticPassword,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.util.Log
|
|||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.example.androidbackupgui.backup.BackupConfig
|
import com.example.androidbackupgui.backup.BackupConfig
|
||||||
|
import com.example.androidbackupgui.backup.security.LegacyCredentialMigrator
|
||||||
import com.example.androidbackupgui.backup.security.PasswordManager
|
import com.example.androidbackupgui.backup.security.PasswordManager
|
||||||
import com.example.androidbackupgui.backup.security.ResticBinary
|
import com.example.androidbackupgui.backup.security.ResticBinary
|
||||||
import com.example.androidbackupgui.backup.restic.ResticWrapper
|
import com.example.androidbackupgui.backup.restic.ResticWrapper
|
||||||
@@ -150,11 +151,19 @@ class ConfigViewModel(
|
|||||||
|
|
||||||
/** Read config from file and refresh restic status. */
|
/** Read config from file and refresh restic status. */
|
||||||
fun load() {
|
fun load() {
|
||||||
|
val migrationResult = LegacyCredentialMigrator.migrate(configFile)
|
||||||
val config = BackupConfig.fromFile(configFile)
|
val config = BackupConfig.fromFile(configFile)
|
||||||
val backendDisplay = deriveBackendDisplay(config.resticBackend, config.resticRepo, config.resticBackendUrl)
|
val backendDisplay = deriveBackendDisplay(config.resticBackend, config.resticRepo, config.resticBackendUrl)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(config = config, backendDisplay = backendDisplay)
|
it.copy(config = config, backendDisplay = backendDisplay)
|
||||||
}
|
}
|
||||||
|
if (migrationResult.migratedResticPassword || migrationResult.migratedBackendPass) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(resticStatus = it.resticStatus.copy(
|
||||||
|
message = "已迁移旧版明文密码到加密存储"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
refreshResticStatus(readResticForm())
|
refreshResticStatus(readResticForm())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,8 +253,8 @@ class ConfigViewModel(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Export the current saved config to a user-selected destination [Uri] (SAF).
|
* Export the current saved config to a user-selected destination [Uri] (SAF).
|
||||||
* Writes the same on-disk config format, including the plaintext restic password,
|
* Writes the same on-disk config format. Passwords are stored as placeholders
|
||||||
* so the warning is surfaced in the UI before export.
|
* in the exported file; actual passwords remain in EncryptedSharedPreferences.
|
||||||
*/
|
*/
|
||||||
fun exportConfig(uri: android.net.Uri) {
|
fun exportConfig(uri: android.net.Uri) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<network-security-config>
|
<network-security-config>
|
||||||
<!--
|
<base-config cleartextTrafficPermitted="false">
|
||||||
WebDAV 后端支持 HTTP(非加密)传输,用户自行选择。
|
|
||||||
cleartextTrafficPermitted="true" 全局允许 HTTP/FTP 等明文流量。
|
|
||||||
如未来需要更精细控制(例如仅允许特定域名走 HTTP),可在此扩展。
|
|
||||||
-->
|
|
||||||
<base-config cleartextTrafficPermitted="true">
|
|
||||||
<trust-anchors>
|
<trust-anchors>
|
||||||
<certificates src="system" />
|
<certificates src="system" />
|
||||||
</trust-anchors>
|
</trust-anchors>
|
||||||
</base-config>
|
</base-config>
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||||
|
<domain includeSubdomains="false">localhost</domain>
|
||||||
|
</domain-config>
|
||||||
</network-security-config>
|
</network-security-config>
|
||||||
|
|||||||
Reference in New Issue
Block a user