From f99585a7c05b922827cf24627a17a13ab36b7d85 Mon Sep 17 00:00:00 2001 From: sakuradairong Date: Wed, 17 Jun 2026 11:24:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(release):=20=E9=98=B6=E6=AE=B56-7=20Restic?= =?UTF-8?q?=20streaming=E6=A0=87=E8=AF=86=E3=80=81=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E6=B2=BB=E7=90=86=E3=80=81CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 阶段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) --- .github/workflows/android.yml | 47 ++++++++++ .github/workflows/release.yml | 47 ++++++++++ .gitignore | 7 ++ app/build.gradle | 18 ++-- app/proguard-rules.pro | 27 +++--- .../androidbackupgui/backup/BackupConfig.kt | 20 ++++- .../backup/restic/ResticStreamBackup.kt | 15 ++++ .../security/LegacyCredentialMigrator.kt | 88 +++++++++++++++++++ .../androidbackupgui/ui/ConfigScreen.kt | 40 ++++++--- .../androidbackupgui/ui/ConfigViewModel.kt | 13 ++- .../main/res/xml/network_security_config.xml | 11 ++- 11 files changed, 290 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/android.yml create mode 100644 .github/workflows/release.yml create mode 100644 app/src/main/java/com/example/androidbackupgui/backup/security/LegacyCredentialMigrator.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..fafd442 --- /dev/null +++ b/.github/workflows/android.yml @@ -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/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..93cdc9f --- /dev/null +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 9b0b4e2..33118b0 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/build.gradle b/app/build.gradle index 90cfcac..643f0d1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -48,12 +48,20 @@ android { } buildTypes { release { - if (rootProject.file("app/release.keystore").exists()) { - def ksPass = System.getenv("KEYSTORE_PASSWORD") - def kPass = System.getenv("KEY_PASSWORD") - if (ksPass != null && kPass != null) { - signingConfig signingConfigs.release + 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") + 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 } } } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index a59e959..769c978 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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 { *; } diff --git a/app/src/main/java/com/example/androidbackupgui/backup/BackupConfig.kt b/app/src/main/java/com/example/androidbackupgui/backup/BackupConfig.kt index 6dcf3ec..783b6bb 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/BackupConfig.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/BackupConfig.kt @@ -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" + } } } diff --git a/app/src/main/java/com/example/androidbackupgui/backup/restic/ResticStreamBackup.kt b/app/src/main/java/com/example/androidbackupgui/backup/restic/ResticStreamBackup.kt index 44fa53d..1e8b912 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/restic/ResticStreamBackup.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/restic/ResticStreamBackup.kt @@ -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 ─────────────────── diff --git a/app/src/main/java/com/example/androidbackupgui/backup/security/LegacyCredentialMigrator.kt b/app/src/main/java/com/example/androidbackupgui/backup/security/LegacyCredentialMigrator.kt new file mode 100644 index 0000000..4b32e98 --- /dev/null +++ b/app/src/main/java/com/example/androidbackupgui/backup/security/LegacyCredentialMigrator.kt @@ -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("\"") + } +} diff --git a/app/src/main/java/com/example/androidbackupgui/ui/ConfigScreen.kt b/app/src/main/java/com/example/androidbackupgui/ui/ConfigScreen.kt index b11e60a..5c6079b 100644 --- a/app/src/main/java/com/example/androidbackupgui/ui/ConfigScreen.kt +++ b/app/src/main/java/com/example/androidbackupgui/ui/ConfigScreen.kt @@ -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,18 +336,24 @@ fun ConfigScreen( } // ── Streaming backup toggle ── - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "实验性 Restic 临时目录备份", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + ) + Switch( + checked = streamingEnabled, + onCheckedChange = { streamingEnabled = it }, + ) + } Text( - "流式备份 (FIFO管道 → restic --stdin)", - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodyMedium, - ) - Switch( - checked = streamingEnabled, - onCheckedChange = { streamingEnabled = it }, + "不等同完整备份:不包含 OBB、外部数据、权限、SSAID、Wi-Fi;大应用数据可能被跳过。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, ) } @@ -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, diff --git a/app/src/main/java/com/example/androidbackupgui/ui/ConfigViewModel.kt b/app/src/main/java/com/example/androidbackupgui/ui/ConfigViewModel.kt index ed90544..61348c7 100644 --- a/app/src/main/java/com/example/androidbackupgui/ui/ConfigViewModel.kt +++ b/app/src/main/java/com/example/androidbackupgui/ui/ConfigViewModel.kt @@ -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 { diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 84e2095..b565b61 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,13 +1,12 @@ - - + + + 127.0.0.1 + localhost +