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:
sakuradairong
2026-06-17 11:24:39 +08:00
parent 4a1db6b75b
commit f99585a7c0
11 changed files with 290 additions and 43 deletions

47
.github/workflows/android.yml vendored Normal file
View 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
View 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
View File

@@ -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

View File

@@ -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
} }
} }
} }

View File

@@ -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 { *; }

View File

@@ -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"
}
} }
} }

View File

@@ -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 ───────────────────

View File

@@ -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("\"")
}
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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>