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)
/test/
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 {
release {
if (rootProject.file("app/release.keystore").exists()) {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
def ksFile = rootProject.file("app/release.keystore")
def ksPass = System.getenv("KEYSTORE_PASSWORD")
def kPass = System.getenv("KEY_PASSWORD")
if (ksPass != null && kPass != null) {
signingConfig signingConfigs.release
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.** { *; }
# --- RemoteTransport (WebDAV/SMB) ---
-keep class com.example.androidbackupgui.backup.RemoteTransport { *; }
-keep class com.example.androidbackupgui.backup.restic.RemoteTransport { *; }
# --- Data classes (serialization) ---
-keep class com.example.androidbackupgui.backup.ResticProgress { *; }
-keep class com.example.androidbackupgui.backup.BackupSummary { *; }
-keep class com.example.androidbackupgui.backup.ResticSnapshot { *; }
-keep class com.example.androidbackupgui.backup.RestoreProgress { *; }
-keep class com.example.androidbackupgui.backup.restic.ResticWrapper$ResticProgress { *; }
-keep class com.example.androidbackupgui.backup.restic.ResticWrapper$BackupSummary { *; }
-keep class com.example.androidbackupgui.backup.restic.ResticWrapper$ResticSnapshot { *; }
-keep class com.example.androidbackupgui.backup.RestoreOperation$RestoreProgress { *; }
-keep class com.example.androidbackupgui.backup.BackupConfig { *; }
-keep class com.example.androidbackupgui.backup.AppError { *; }
-keep class com.example.androidbackupgui.backup.AppResult { *; }
-keep class com.example.androidbackupgui.backup.core.AppError { *; }
-keep class com.example.androidbackupgui.backup.core.AppResult { *; }
# --- RemoteTransport implementations ---
-keep class com.example.androidbackupgui.backup.SmbTransport { *; }
-keep class com.example.androidbackupgui.backup.WebdavTransport { *; }
-keep class com.example.androidbackupgui.backup.restic.SmbTransport { *; }
-keep class com.example.androidbackupgui.backup.restic.WebdavTransport { *; }
# --- WifiManager (called from UI, kept for safety) ---
-keep class com.example.androidbackupgui.backup.WifiManager { *; }
# --- Keep data models used by kotlinx.serialization ---
## Keep all model classes that may be referenced via @Serializable
-keep class com.example.androidbackupgui.model.** { *; }
# --- Keep R classes (referenced by code) ---
-keep class com.example.androidbackupgui.R { *; }
# --- jcifs-ng (SMB) keep class/member names for reflection (was MD4Provider) ---
# --- jcifs-ng (SMB) keep class/member names for reflection ---
-keep class jcifs.util.Crypto { *; }
-keep class jcifs.smb.NtlmUtil { *; }
-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
// 0=disabled (default, stable), 1=enabled (experimental, avoids temp files)
val useStreaming: Int = 0,
val allowInsecureWebdav: Int = 0,
val allowInsecureRestServer: Int = 0,
val smbSigningMode: String = "required",
) {
companion object {
/**
@@ -181,7 +184,7 @@ data class BackupConfig(
blacklist = lines("blacklist"),
whitelist = lines("whitelist"),
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 },
rgbB = int("rgb_b").let { if (it == 0) 123 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"),
resticBackendDomain = str("restic_backend_domain"),
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=\"")
config.system.forEach { append(" ${it.replace(" ", "%20")}") }
appendLine(" \"")
appendLine("Compression_method=${config.compressionMethod}")
appendLine("Compression_method=${normalizeCompressionMethod(config.compressionMethod)}")
appendLine("rgb_a=${config.rgbA}")
appendLine("rgb_b=${config.rgbB}")
appendLine("rgb_c=${config.rgbC}")
@@ -253,10 +259,20 @@ data class BackupConfig(
appendLine("restic_backend_share=\"${escapeValue(config.resticBackendShare)}\"")
appendLine("restic_backend_domain=\"${escapeValue(config.resticBackendDomain)}\"")
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.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"),
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}")
// ── 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
ignoreRunning = config.backgroundAppsIgnore == 1
outputPath = config.outputPath
compressionMethod = config.compressionMethod
compressionMethod = BackupConfig.normalizeCompressionMethod(config.compressionMethod)
backupUserId = config.backupUserId
resticEnabled = config.resticEnabled == 1
resticRepo = config.resticRepo
@@ -289,6 +289,14 @@ fun ConfigScreen(
label = { Text(backendDisplay.urlHint.ifEmpty { "后端地址" }) },
modifier = Modifier.fillMaxWidth(),
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") {
@@ -328,12 +336,12 @@ fun ConfigScreen(
}
// ── Streaming backup toggle ──
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"流式备份 (FIFO管道 → restic --stdin)",
"实验性 Restic 临时目录备份",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
)
@@ -342,6 +350,12 @@ fun ConfigScreen(
onCheckedChange = { streamingEnabled = it },
)
}
Text(
"不等同完整备份:不包含 OBB、外部数据、权限、SSAID、Wi-Fi大应用数据可能被跳过。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Spacer(Modifier.height(8.dp))
@@ -477,7 +491,7 @@ fun ConfigScreen(
backgroundAppsIgnore = if (ignoreRunning) 1 else 0,
backupUserId = backupUserId,
outputPath = outputPath,
compressionMethod = compressionMethod.ifEmpty { "zstd" },
compressionMethod = BackupConfig.normalizeCompressionMethod(compressionMethod),
resticEnabled = if (resticEnabled) 1 else 0,
resticRepo = resticRepo,
resticPassword = resticPassword,

View File

@@ -4,6 +4,7 @@ import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
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.ResticBinary
import com.example.androidbackupgui.backup.restic.ResticWrapper
@@ -150,11 +151,19 @@ class ConfigViewModel(
/** Read config from file and refresh restic status. */
fun load() {
val migrationResult = LegacyCredentialMigrator.migrate(configFile)
val config = BackupConfig.fromFile(configFile)
val backendDisplay = deriveBackendDisplay(config.resticBackend, config.resticRepo, config.resticBackendUrl)
_uiState.update {
it.copy(config = config, backendDisplay = backendDisplay)
}
if (migrationResult.migratedResticPassword || migrationResult.migratedBackendPass) {
_uiState.update {
it.copy(resticStatus = it.resticStatus.copy(
message = "已迁移旧版明文密码到加密存储"
))
}
}
refreshResticStatus(readResticForm())
}
@@ -244,8 +253,8 @@ class ConfigViewModel(
/**
* 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.
* Writes the same on-disk config format. Passwords are stored as placeholders
* in the exported file; actual passwords remain in EncryptedSharedPreferences.
*/
fun exportConfig(uri: android.net.Uri) {
viewModelScope.launch {

View File

@@ -1,13 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!--
WebDAV 后端支持 HTTP非加密传输用户自行选择。
cleartextTrafficPermitted="true" 全局允许 HTTP/FTP 等明文流量。
如未来需要更精细控制(例如仅允许特定域名走 HTTP可在此扩展。
-->
<base-config cleartextTrafficPermitted="true">
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>