Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3813f49a12 | ||
|
|
b2ea0c7960 | ||
|
|
058bf23465 | ||
|
|
7fec4c52a1 | ||
|
|
32182b592e | ||
|
|
bb7dc9a700 | ||
|
|
b01569416d | ||
|
|
26823fcb6f | ||
|
|
6f6549d897 | ||
|
|
c10505fc10 |
18
README.md
18
README.md
@@ -9,6 +9,7 @@ Android 应用备份与恢复工具,集成 [restic](https://restic.net/) 实
|
||||
- **并行备份/恢复** — 备份并发数 3(Semaphore(3)),恢复并发数 2(Semaphore(2))
|
||||
- **存档完整性校验** — 备份后自动 zstd/gzip 校验数据归档
|
||||
- **restic 增量去重** — 内建 `librestic.so`(~24MB),支持本地和远端仓库
|
||||
- **构建体积优化** — Release APK 仅 11.8 MB(ProGuard/R8 full mode + shrinkResources + BouncyCastle PQC 移除)
|
||||
- **远程后端** — WebDAV(如 123 云盘)/ SMB 协议,本地临时仓库 + 自动双向同步 + 进度回调
|
||||
- **配置持久化** — 仓库路径、密码、后端参数保存在 `backup_settings.conf`
|
||||
- **快照管理** — 初始化仓库、查看统计、按策略清理旧快照(保留 7 天/4 周/3 月)
|
||||
@@ -98,17 +99,26 @@ ConfigViewModel ResticWrapper
|
||||
- **文件大小限制** — WebDAV 上传 50MB 上限(防止 ByteArray OOM)
|
||||
- **存档完整性校验** — 备份后 zstd/gzip 验证数据归档,校验失败回告
|
||||
|
||||
## 编译
|
||||
## 构建
|
||||
|
||||
### 版本历史
|
||||
|
||||
|-|版本|更新内容|
|
||||
|-|---:|--------|
|
||||
| | v1.3 | 累积快照、AppResult 类型化错误、RootShell Mutex、kotlinx-serialization 迁移 |
|
||||
| | v1.4 | APK 体积优化(ProGuard/R8 + shrinkResources + 依赖裁剪),Release APK 从 25 MB 降至 11.8 MB(-52.8%) |
|
||||
|
||||
### 编译命令
|
||||
|
||||
```bash
|
||||
# Debug APK
|
||||
# Debug APK(不压缩,适合开发调试)
|
||||
./gradlew assembleDebug
|
||||
|
||||
# Release (需配置签名)
|
||||
# Release APK(ProGuard/R8 混淆 + 资源裁剪 + 签名)
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
|
||||
`librestic.so` 需放在 `app/src/main/jniLibs/arm64-v8a/` 目录下,在 `build.gradle` 中禁用 `extractNativeLibs` 前的 `useLegacyPackaging`。
|
||||
> Release 构建需配置 `release.keystore` 签名文件;`librestic.so` 放在 `app/src/main/jniLibs/arm64-v8a/` 下。
|
||||
|
||||
## 使用说明
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ android {
|
||||
applicationId "com.example.androidbackupgui"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 4
|
||||
versionName "1.3"
|
||||
versionCode 12
|
||||
versionName "1.11"
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
@@ -37,20 +37,17 @@ android {
|
||||
}
|
||||
signingConfigs {
|
||||
release {
|
||||
def keystoreFile = file("release.keystore")
|
||||
if (keystoreFile.exists()) {
|
||||
storeFile keystoreFile
|
||||
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "android"
|
||||
keyAlias "release"
|
||||
keyPassword System.getenv("KEY_PASSWORD") ?: "android"
|
||||
}
|
||||
storeFile rootProject.file("app/release.keystore")
|
||||
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "android"
|
||||
keyAlias "release"
|
||||
keyPassword System.getenv("KEY_PASSWORD") ?: "android"
|
||||
v1SigningEnabled true
|
||||
v2SigningEnabled true
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
if (file("release.keystore").exists()) {
|
||||
if (rootProject.file("app/release.keystore").exists()) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
@@ -72,6 +69,13 @@ android {
|
||||
jniLibs {
|
||||
useLegacyPackaging true
|
||||
}
|
||||
resources {
|
||||
excludes += [
|
||||
'org/bouncycastle/pqc/crypto/picnic/lowmcL5.bin.properties',
|
||||
'org/bouncycastle/pqc/crypto/picnic/lowmcL3.bin.properties',
|
||||
'org/bouncycastle/pqc/crypto/picnic/lowmcL1.bin.properties',
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,12 +93,19 @@ dependencies {
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
|
||||
|
||||
// 方案A: jcifs-ng (SMB) + sardine-android (WebDAV) 替代 rclone serve
|
||||
implementation "eu.agno3.jcifs:jcifs-ng:2.1.10"
|
||||
implementation "com.github.thegrizzlylabs:sardine-android:v0.9"
|
||||
implementation("eu.agno3.jcifs:jcifs-ng:2.1.10") {
|
||||
exclude group: 'org.bouncycastle'
|
||||
}
|
||||
implementation("com.github.thegrizzlylabs:sardine-android:v0.9") {
|
||||
exclude group: 'xpp3'
|
||||
exclude group: 'stax'
|
||||
}
|
||||
implementation "org.slf4j:slf4j-android:1.7.36"
|
||||
|
||||
// root shell via libsu (Magisk/KernelSU/APatch)
|
||||
implementation 'com.github.topjohnwu:libsu:6.0.0'
|
||||
// Full BouncyCastle provider (includes MD4 required by jcifs-ng SMB)
|
||||
implementation 'org.bouncycastle:bcprov-jdk15to18:1.77'
|
||||
implementation 'org.nanohttpd:nanohttpd:2.3.1'
|
||||
testImplementation "io.kotest:kotest-runner-junit5:5.9.1"
|
||||
testImplementation "io.kotest:kotest-assertions-core:5.9.1"
|
||||
|
||||
59
app/proguard-rules.pro
vendored
59
app/proguard-rules.pro
vendored
@@ -1 +1,58 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# ProGuard/R8 rules for Android Backup GUI
|
||||
# ==========================================
|
||||
|
||||
# --- kotlinx.serialization ---
|
||||
# Keep @SerialName classes and companion serializer fields
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.AnnotationsKt
|
||||
-keepclassmembers class kotlinx.serialization.json.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class kotlinx.serialization.json.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
-keep,includedescriptorclasses class com.example.androidbackupgui.**$$serializer { *; }
|
||||
-keepclassmembers class com.example.androidbackupgui.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class com.example.androidbackupgui.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# --- NanoHTTPD ---
|
||||
# NanoHTTPD (package fi.iki.elonen despite Maven group org.nanohttpd)
|
||||
-keep class fi.iki.elonen.** { *; }
|
||||
|
||||
# --- RemoteTransport (WebDAV/SMB) ---
|
||||
-keep class com.example.androidbackupgui.backup.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.BackupConfig { *; }
|
||||
-keep class com.example.androidbackupgui.backup.AppError { *; }
|
||||
-keep class com.example.androidbackupgui.backup.AppResult { *; }
|
||||
|
||||
|
||||
# --- RemoteTransport implementations ---
|
||||
-keep class com.example.androidbackupgui.backup.SmbTransport { *; }
|
||||
-keep class com.example.androidbackupgui.backup.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 MD4Provider reflection ---
|
||||
-keep class jcifs.util.Crypto { *; }
|
||||
-keep class jcifs.smb.NtlmUtil { *; }
|
||||
-keep class jcifs.ntlmssp.Type3Message { *; }
|
||||
-keep class jcifs.smb.NtlmContext { *; }
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import org.bouncycastle.crypto.digests.MD4Digest
|
||||
import java.security.MessageDigest
|
||||
import java.security.MessageDigestSpi
|
||||
import java.security.Provider
|
||||
import java.security.Security
|
||||
|
||||
/**
|
||||
* Ensures MD4 [MessageDigest] is available for jcifs-ng on Android.
|
||||
*
|
||||
* jcifs-ng 2.1.x obtains MD4 by instantiating [BouncyCastleProvider]
|
||||
* and calling [MessageDigest.getInstance]("MD4", bcProvider).
|
||||
* Android's BouncyCastleProvider class is shadowed by the boot classloader
|
||||
* and lacks MD4.
|
||||
*
|
||||
* Strategy: use reflection to replace `jcifs.util.Crypto.provider`
|
||||
* with a delegating provider that wraps Android's BC and adds MD4.
|
||||
* The MD4 [MessageDigestSpi] implementation comes from [MD4Digest]
|
||||
* in bcprov-jdk15to18 (not shadowed — the class is not in boot CL).
|
||||
*/
|
||||
object MD4Provider {
|
||||
|
||||
private const val TAG = "MD4Provider"
|
||||
private val registered = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
|
||||
private val md4Provider: Provider by lazy {
|
||||
val bc = Security.getProvider("BC")
|
||||
Md4DelegatingProvider(bc)
|
||||
}
|
||||
|
||||
fun register() {
|
||||
if (!registered.compareAndSet(false, true)) return
|
||||
try {
|
||||
// 1. Replace cached provider in every jcifs-ng class that has one
|
||||
setProviderField("jcifs.util.Crypto")
|
||||
for (cn in listOf(
|
||||
"jcifs.smb.NtlmUtil",
|
||||
"jcifs.smb.NtlmPasswordAuthenticator",
|
||||
"jcifs.ntlmssp.Type3Message",
|
||||
"jcifs.smb.NtlmContext"
|
||||
)) setProviderField(cn)
|
||||
|
||||
// 2. Verify by checking what Crypto.getProvider() returns
|
||||
try {
|
||||
val cl = Class.forName("jcifs.util.Crypto")
|
||||
val getProv = cl.getDeclaredMethod("getProvider")
|
||||
getProv.isAccessible = true
|
||||
val actual = getProv.invoke(null) as Provider
|
||||
Log.i(TAG, "Crypto.getProvider() => ${actual::class.java.simpleName} (hasMD4=${actual.getService("MessageDigest", "MD4") != null})")
|
||||
} catch (_: Exception) {}
|
||||
|
||||
// 3. Fallback: register a global MD4 provider too
|
||||
try {
|
||||
Security.insertProviderAt(Md4StandaloneProvider(), 1)
|
||||
} catch (_: Exception) {}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to inject MD4", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setProviderField(clsName: String) {
|
||||
try {
|
||||
val cls = Class.forName(clsName)
|
||||
for (f in cls.declaredFields) {
|
||||
if (java.lang.reflect.Modifier.isStatic(f.modifiers) &&
|
||||
Provider::class.java.isAssignableFrom(f.type)) {
|
||||
f.isAccessible = true
|
||||
f.set(null, md4Provider)
|
||||
Log.i(TAG, "Set $clsName.${f.name} = Md4DelegatingProvider")
|
||||
return
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "No static Provider field in $clsName")
|
||||
} catch (_: ClassNotFoundException) {
|
||||
Log.i(TAG, "Class not found: $clsName")
|
||||
}
|
||||
}
|
||||
|
||||
// ── MD4 MessageDigestSpi ────────────────────────────────────
|
||||
|
||||
class Md4DigestSpi : MessageDigestSpi() {
|
||||
private val d = MD4Digest()
|
||||
override fun engineGetDigestLength() = d.digestSize
|
||||
override fun engineUpdate(b: Byte) { d.update(b) }
|
||||
override fun engineUpdate(b: ByteArray, o: Int, l: Int) { d.update(b, o, l) }
|
||||
override fun engineDigest(): ByteArray {
|
||||
val r = ByteArray(d.digestSize); d.doFinal(r, 0); return r
|
||||
}
|
||||
override fun engineReset() { d.reset() }
|
||||
}
|
||||
|
||||
// ── Delegating provider ─────────────────────────────────────
|
||||
|
||||
/** A "BC"-named provider that delegates to [bc] except for MD4. */
|
||||
private class Md4DelegatingProvider(
|
||||
private val bc: Provider?
|
||||
) : Provider("BC", bc?.version ?: 1.0, "BC + MD4") {
|
||||
|
||||
init {
|
||||
// Register MD4 service in the provider's internal service map
|
||||
putService(Service(this, "MessageDigest", "MD4",
|
||||
Md4DigestSpi::class.java.name, null, null))
|
||||
}
|
||||
|
||||
override fun getService(type: String, algorithm: String): Service? {
|
||||
if (type == "MessageDigest" && algorithm.equals("MD4", ignoreCase = true)) {
|
||||
return super.getService(type, algorithm)
|
||||
}
|
||||
return bc?.getService(type, algorithm)
|
||||
}
|
||||
|
||||
override fun getServices(): MutableSet<Service> {
|
||||
val s = (bc?.getServices() ?: emptySet<Service>()).toMutableSet()
|
||||
s.addAll(super.getServices())
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
/** Standalone MD4-only provider registered globally as fallback. */
|
||||
private class Md4StandaloneProvider : Provider("Md4Provider", 1.0, "MD4 only") {
|
||||
override fun getService(type: String, algorithm: String): Service? {
|
||||
if (type == "MessageDigest" && algorithm.equals("MD4", ignoreCase = true)) {
|
||||
return Service(this, type, algorithm, Md4DigestSpi::class.java.name, null, null)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import org.bouncycastle.crypto.digests.MD4Digest
|
||||
import org.bouncycastle.crypto.engines.AESEngine
|
||||
import org.bouncycastle.crypto.macs.CMac
|
||||
import org.bouncycastle.crypto.params.KeyParameter
|
||||
import java.security.MessageDigest
|
||||
import java.security.MessageDigestSpi
|
||||
import java.security.Provider
|
||||
import java.security.Security
|
||||
import java.security.spec.AlgorithmParameterSpec
|
||||
import javax.crypto.MacSpi
|
||||
|
||||
/**
|
||||
* Injects missing algorithms (MD4, AESCMAC) into Android's BC provider
|
||||
* for jcifs-ng SMB support.
|
||||
*
|
||||
* jcifs-ng instantiates [BouncyCastleProvider] and requests algorithms
|
||||
* ([MessageDigest]"MD4", [Mac]"AESCMAC") that Android's built-in BC
|
||||
* has removed. The BouncyCastleProvider class is shadowed by the boot
|
||||
* classloader, so we patch `jcifs.util.Crypto.provider` via reflection.
|
||||
*/
|
||||
object MissingAlgoProvider {
|
||||
|
||||
private const val TAG = "MissingAlgoProvider"
|
||||
private val registered = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
|
||||
private val patchProvider: Provider by lazy {
|
||||
val bc = Security.getProvider("BC")
|
||||
DelegatingBcProvider(bc)
|
||||
}
|
||||
|
||||
fun register() {
|
||||
if (!registered.compareAndSet(false, true)) return
|
||||
try {
|
||||
// 1. Replace cached provider in jcifs-ng classes
|
||||
for (cn in listOf(
|
||||
"jcifs.util.Crypto",
|
||||
"jcifs.smb.NtlmUtil",
|
||||
"jcifs.smb.NtlmPasswordAuthenticator",
|
||||
"jcifs.ntlmssp.Type3Message",
|
||||
"jcifs.smb.NtlmContext"
|
||||
)) setProviderField(cn)
|
||||
|
||||
// 2. Verify
|
||||
try {
|
||||
val cl = Class.forName("jcifs.util.Crypto")
|
||||
val getProv = cl.getDeclaredMethod("getProvider")
|
||||
getProv.isAccessible = true
|
||||
val actual = getProv.invoke(null) as Provider
|
||||
Log.i(TAG, "Crypto.getProvider() => ${actual::class.java.simpleName} " +
|
||||
"(hasMD4=${actual.getService("MessageDigest", "MD4") != null}, " +
|
||||
"hasAESCMAC=${actual.getService("Mac", "AESCMAC") != null})")
|
||||
} catch (ve: Exception) {
|
||||
Log.w(TAG, "Verification failed after injection", ve)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to inject algorithms", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setProviderField(clsName: String) {
|
||||
try {
|
||||
val cls = Class.forName(clsName)
|
||||
for (f in cls.declaredFields) {
|
||||
if (java.lang.reflect.Modifier.isStatic(f.modifiers) &&
|
||||
Provider::class.java.isAssignableFrom(f.type)) {
|
||||
f.isAccessible = true
|
||||
f.set(null, patchProvider)
|
||||
Log.i(TAG, "Set $clsName.${f.name} = DelegatingBcProvider")
|
||||
return
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "No static Provider field in $clsName")
|
||||
} catch (_: ClassNotFoundException) {
|
||||
Log.i(TAG, "Class not found: $clsName")
|
||||
}
|
||||
}
|
||||
|
||||
// ── MD4 MessageDigestSpi ────────────────────────────────────
|
||||
|
||||
class Md4Spi : MessageDigestSpi() {
|
||||
private val d = MD4Digest()
|
||||
override fun engineGetDigestLength() = d.digestSize
|
||||
override fun engineUpdate(b: Byte) { d.update(b) }
|
||||
override fun engineUpdate(b: ByteArray, o: Int, l: Int) { d.update(b, o, l) }
|
||||
override fun engineDigest(): ByteArray {
|
||||
val r = ByteArray(d.digestSize); d.doFinal(r, 0); return r
|
||||
}
|
||||
override fun engineReset() { d.reset() }
|
||||
}
|
||||
|
||||
// ── AESCMAC MacSpi ─────────────────────────────────────────
|
||||
class AesCmacSpi : MacSpi() {
|
||||
private val mac = CMac(AESEngine.newInstance())
|
||||
override fun engineInit(key: java.security.Key, params: AlgorithmParameterSpec?) {
|
||||
val raw = key.encoded ?: throw java.security.InvalidKeyException("AESCMAC key has no encoded form")
|
||||
mac.init(KeyParameter(raw))
|
||||
}
|
||||
override fun engineUpdate(inp: Byte) { mac.update(inp) }
|
||||
override fun engineUpdate(inp: ByteArray, o: Int, l: Int) { mac.update(inp, o, l) }
|
||||
override fun engineDoFinal(): ByteArray {
|
||||
val r = ByteArray(mac.macSize); mac.doFinal(r, 0); return r
|
||||
}
|
||||
override fun engineGetMacLength() = mac.macSize
|
||||
override fun engineReset() { mac.reset() }
|
||||
}
|
||||
|
||||
// ── Delegating provider ─────────────────────────────────────
|
||||
|
||||
/** A "BC"-named provider that delegates to [bc] except for patched algorithms. */
|
||||
private class DelegatingBcProvider(
|
||||
private val bc: Provider?
|
||||
) : Provider("BC", bc?.version ?: 1.0, "BC + patches") {
|
||||
|
||||
init {
|
||||
putService(Service(this, "MessageDigest", "MD4",
|
||||
Md4Spi::class.java.name, null, null))
|
||||
putService(Service(this, "Mac", "AESCMAC",
|
||||
AesCmacSpi::class.java.name, null, null))
|
||||
}
|
||||
|
||||
override fun getService(type: String, algorithm: String): Service? {
|
||||
if (type == "MessageDigest" && algorithm.equals("MD4", ignoreCase = true)) return super.getService(type, algorithm)
|
||||
if (type == "Mac" && algorithm.equals("AESCMAC", ignoreCase = true)) return super.getService(type, algorithm)
|
||||
return bc?.getService(type, algorithm)
|
||||
}
|
||||
|
||||
override fun getServices(): MutableSet<Service> {
|
||||
val s = (bc?.getServices() ?: emptySet<Service>()).toMutableSet()
|
||||
s.addAll(super.getServices())
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ class RestBridgeRunner {
|
||||
|
||||
private val TAG = "RestBridgeRunner"
|
||||
|
||||
/** Cached transport to reuse SMB sessions across bridge instances. */
|
||||
private var cachedTransport: RemoteTransport? = null
|
||||
private var cachedTransportKey: String? = null
|
||||
|
||||
/**
|
||||
* Start a REST bridge for the given [backend], execute [block] with the
|
||||
* bridge URL, then stop and clean up.
|
||||
@@ -49,14 +53,22 @@ class RestBridgeRunner {
|
||||
return block(repoPath)
|
||||
}
|
||||
|
||||
val transport = transportFactory(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain)
|
||||
?: return block(repoPath)
|
||||
// Reuse cached transport (same SMB session) for consistent cross-bridge visibility
|
||||
val key = "$backend|$backendUrl|$backendUser|$backendShare|$backendDomain"
|
||||
if (cachedTransportKey != key) {
|
||||
cachedTransport?.let { Log.d(TAG, "discarding stale cached transport") }
|
||||
val t = transportFactory(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain)
|
||||
?: return block(repoPath)
|
||||
cachedTransport = t
|
||||
cachedTransportKey = key
|
||||
}
|
||||
val transport = cachedTransport!!
|
||||
|
||||
val remoteBase = buildRemoteBase(backend, backendUrl, backendShare, repoPath)
|
||||
val bridge = ResticRestBridge(transport, remoteBase, cacheDir)
|
||||
val bridge = ResticRestBridge(transport, remoteBase, repoPath, cacheDir)
|
||||
|
||||
try {
|
||||
bridge.start()
|
||||
bridge.start(0)
|
||||
val port = bridge.listeningPort
|
||||
if (port < 0) {
|
||||
throw IllegalStateException("REST bridge failed to bind a port")
|
||||
|
||||
@@ -63,22 +63,72 @@ class ResticRepoInit(
|
||||
if (result.exitCode == 0) {
|
||||
return AppResult.Success(Unit)
|
||||
}
|
||||
// exitCode 1 = config already exists; verify the repo is actually usable
|
||||
// exitCode 1: check if it's "config already exists" or a real error
|
||||
if (result.exitCode == 1) {
|
||||
val verify = runner.runRestic(env, "snapshots", "--json")
|
||||
if (!isConfigExistsError(result.stderr)) {
|
||||
// Exit code 1 from restic can also mean connection/backend errors (500, timeout, etc.)
|
||||
return err(AppError.Restic("restic init 失败: ${result.stderr.take(300).trim()}", result.exitCode, result.stderr))
|
||||
}
|
||||
var verify = runner.runRestic(env, "snapshots", "--json")
|
||||
if (verify.exitCode == 0) {
|
||||
// Repo is healthy — already initialized with matching password
|
||||
Log.i(TAG, "init: repo already initialized and verified")
|
||||
return AppResult.Success(Unit)
|
||||
}
|
||||
// Config exists but repo is corrupted (wrong password, missing keys, etc.)
|
||||
// Lock-related failure → try unlock then retry
|
||||
if (isLockError(verify.stderr)) {
|
||||
Log.w(TAG, "init: stale lock detected, running unlock")
|
||||
runner.runRestic(env, "unlock")
|
||||
verify = runner.runRestic(env, "snapshots", "--json")
|
||||
if (verify.exitCode == 0) {
|
||||
Log.i(TAG, "init: repo verified after unlock")
|
||||
return AppResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
// Config exists but verification failed — diagnose the cause
|
||||
val detail = diagnoseInitFailure(verify.stderr)
|
||||
return err(
|
||||
AppError.Restic("仓库已存在但无法验证", verify.exitCode, verify.stderr)
|
||||
AppError.Restic("仓库已存在但无法验证: $detail", verify.exitCode, verify.stderr)
|
||||
)
|
||||
}
|
||||
return err(AppError.Restic("restic init 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
|
||||
/** Check if [restic init]'s stderr indicates config already exists (vs a real error). */
|
||||
private fun isConfigExistsError(stderr: String): Boolean {
|
||||
val lower = stderr.lowercase()
|
||||
return lower.contains("already exists") ||
|
||||
lower.contains("config file already exists")
|
||||
}
|
||||
|
||||
/** Check if stderr indicates a stale repository lock. */
|
||||
private fun isLockError(stderr: String): Boolean {
|
||||
val lower = stderr.lowercase()
|
||||
return lower.contains("lock") ||
|
||||
lower.contains("unable to create") ||
|
||||
lower.contains("already locked")
|
||||
}
|
||||
|
||||
/** Parse restic stderr to produce a user-facing diagnosis string. */
|
||||
private fun diagnoseInitFailure(stderr: String): String {
|
||||
val lower = stderr.lowercase()
|
||||
return when {
|
||||
lower.contains("wrong password") ||
|
||||
lower.contains("password is incorrect") ||
|
||||
lower.contains("unable to decrypt") ||
|
||||
lower.contains("wrong key") ||
|
||||
lower.contains("invalid password") ||
|
||||
lower.contains("decryption") -> "密码不正确,请确认仓库密码"
|
||||
lower.contains("key") && (lower.contains("not found") || lower.contains("missing")) ->
|
||||
"密钥文件缺失,仓库可能已损坏"
|
||||
lower.contains("permission") || lower.contains("access denied") ->
|
||||
"权限不足,请检查目录权限"
|
||||
lower.contains("not a directory") || lower.contains("no such file") ->
|
||||
"仓库路径无效或不可访问"
|
||||
else -> "仓库可能已损坏或密码不正确(${stderr.take(200).trim()})"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public URL helper ──────────────────────────────
|
||||
|
||||
/** Build a display-friendly repository URL for UI. */
|
||||
|
||||
@@ -6,7 +6,6 @@ import fi.iki.elonen.NanoHTTPD.IHTTPSession
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* NanoHTTPD-based REST bridge implementing the restic REST backend API.
|
||||
*
|
||||
@@ -14,10 +13,15 @@ import java.util.UUID
|
||||
* can read/write blobs directly to SMB/WebDAV without a local staging repo.
|
||||
*
|
||||
* Port is auto-assigned (0); use [listeningPort] after start().
|
||||
*
|
||||
* @param repoPath repository path from the bridge URL (e.g. "backup").
|
||||
* Stripped from incoming URIs so that the remoteBase SMB path
|
||||
* does not get double-nested with the repo prefix.
|
||||
*/
|
||||
class ResticRestBridge(
|
||||
private val transport: RemoteTransport,
|
||||
private val remoteBase: String,
|
||||
private val repoPath: String,
|
||||
private val cacheDir: File
|
||||
) : NanoHTTPD(0) {
|
||||
|
||||
@@ -56,6 +60,14 @@ class ResticRestBridge(
|
||||
session: IHTTPSession
|
||||
): Response {
|
||||
val path = uri.trimEnd('/')
|
||||
// Strip the repoPath prefix (/backup/...) from the URI so that type/name
|
||||
// parsing sees only the restic REST API segment.
|
||||
val stripPrefix = if (repoPath.isNotEmpty()) "/${repoPath.trim('/')}" else ""
|
||||
val strippedPath = if (stripPrefix.isNotEmpty() && path.startsWith(stripPrefix)) {
|
||||
path.removePrefix(stripPrefix).ifEmpty { "/" }
|
||||
} else {
|
||||
path
|
||||
}
|
||||
|
||||
// POST {path}?create=true -> mkdirs
|
||||
if (method == NanoHTTPD.Method.POST && params["create"] == "true") {
|
||||
@@ -71,7 +83,7 @@ class ResticRestBridge(
|
||||
}
|
||||
}
|
||||
|
||||
val segments = path.split("/").filter { it.isNotEmpty() }
|
||||
val segments = strippedPath.split("/").filter { it.isNotEmpty() }
|
||||
|
||||
if (segments.isEmpty()) {
|
||||
return newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Invalid path")
|
||||
@@ -109,13 +121,42 @@ class ResticRestBridge(
|
||||
* Stream body from session input to a temp file to avoid OOM on large blobs.
|
||||
* Returns the temp file (caller must delete).
|
||||
*/
|
||||
private fun streamBodyToFile(session: IHTTPSession, tmpDir: File): File? {
|
||||
private fun streamBodyToFile(session: IHTTPSession, tmpDir: File): Result<File> {
|
||||
val started = System.currentTimeMillis()
|
||||
return try {
|
||||
val tmpFile = File(tmpDir, "restic_blob_${UUID.randomUUID()}")
|
||||
val contentLength = session.headers["content-length"]?.toLongOrNull() ?: -1L
|
||||
val input = (session as NanoHTTPD.HTTPSession).inputStream
|
||||
tmpFile.outputStream().use { output -> input.copyTo(output) }
|
||||
tmpFile
|
||||
} catch (_: Exception) { null }
|
||||
Log.d(TAG, "streamBodyToFile: reading body (content-length=$contentLength)...")
|
||||
tmpFile.outputStream().use { output ->
|
||||
if (contentLength > 0) {
|
||||
// Read exactly Content-Length bytes to avoid blocking on keep-alive
|
||||
val buf = ByteArray(8192)
|
||||
var remaining = contentLength
|
||||
while (remaining > 0) {
|
||||
val toRead = minOf(buf.size.toLong(), remaining).toInt()
|
||||
val n = input.read(buf, 0, toRead)
|
||||
if (n == -1) break
|
||||
output.write(buf, 0, n)
|
||||
remaining -= n
|
||||
}
|
||||
if (remaining > 0) {
|
||||
Log.w(TAG, "streamBodyToFile: body truncated, expected $contentLength bytes but got EOF after ${contentLength - remaining}")
|
||||
}
|
||||
Unit
|
||||
} else {
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
val elapsed = System.currentTimeMillis() - started
|
||||
val bytes = tmpFile.length()
|
||||
Log.i(TAG, "streamBodyToFile: read $bytes bytes in ${elapsed}ms")
|
||||
Result.success(tmpFile)
|
||||
} catch (e: Exception) {
|
||||
val elapsed = System.currentTimeMillis() - started
|
||||
Log.w(TAG, "streamBodyToFile failed after ${elapsed}ms", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@@ -145,8 +186,8 @@ class ResticRestBridge(
|
||||
try {
|
||||
when (transport.download(remotePath, tempFile.absolutePath)) {
|
||||
is AppResult.Success -> {
|
||||
val bytes = tempFile.readBytes()
|
||||
newChunkedResponse(Response.Status.OK, "application/octet-stream", bytes.inputStream())
|
||||
val data = tempFile.readBytes()
|
||||
newFixedLengthResponse(Response.Status.OK, "application/octet-stream", data.inputStream(), data.size.toLong())
|
||||
}
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND, "text/plain", ""
|
||||
@@ -157,10 +198,12 @@ class ResticRestBridge(
|
||||
}
|
||||
}
|
||||
NanoHTTPD.Method.POST -> {
|
||||
val tmpFile = streamBodyToFile(session, cacheDir)
|
||||
if (tmpFile == null) return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain", "body read failed"
|
||||
val tmpResult = streamBodyToFile(session, cacheDir)
|
||||
if (tmpResult.isFailure) return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain",
|
||||
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}"
|
||||
)
|
||||
val tmpFile = tmpResult.getOrThrow()
|
||||
try {
|
||||
when (transport.upload(tmpFile.absolutePath, remotePath)) {
|
||||
is AppResult.Success -> newFixedLengthResponse(
|
||||
@@ -275,14 +318,14 @@ class ResticRestBridge(
|
||||
response.addHeader("Content-Length", chunkSize.toString())
|
||||
return@runBlocking response
|
||||
}
|
||||
|
||||
// Full file — stream directly without loading into memory
|
||||
// Full file — read into memory (blobs are typically small)
|
||||
val data = tempFile.readBytes()
|
||||
val response = newChunkedResponse(
|
||||
Response.Status.OK,
|
||||
"application/octet-stream",
|
||||
tempFile.inputStream()
|
||||
data.inputStream()
|
||||
)
|
||||
response.addHeader("Content-Length", tempFile.length().toString())
|
||||
response.addHeader("Content-Length", data.size.toString())
|
||||
response
|
||||
}
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
@@ -302,10 +345,12 @@ class ResticRestBridge(
|
||||
session: IHTTPSession
|
||||
): Response = runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
val tmpFile = streamBodyToFile(session, cacheDir)
|
||||
if (tmpFile == null) return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain", "body read failed"
|
||||
val tmpResult = streamBodyToFile(session, cacheDir)
|
||||
if (tmpResult.isFailure) return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain",
|
||||
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}"
|
||||
)
|
||||
val tmpFile = tmpResult.getOrThrow()
|
||||
try {
|
||||
when (transport.upload(tmpFile.absolutePath, remotePath)) {
|
||||
is AppResult.Success -> newFixedLengthResponse(
|
||||
|
||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.io.File
|
||||
import java.util.Properties
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class SmbTransport(
|
||||
private val host: String,
|
||||
@@ -22,10 +23,21 @@ class SmbTransport(
|
||||
private val password: String,
|
||||
private val domain: String = "",
|
||||
private val bufferSize: Int = 8192,
|
||||
private val smbSigning: Boolean = true
|
||||
private val smbSigning: Boolean = false
|
||||
): RemoteTransport {
|
||||
companion object { private const val TAG = "SmbTransport" }
|
||||
companion object {
|
||||
private const val TAG = "SmbTransport"
|
||||
|
||||
/** Register missing JCA algorithms for jcifs-ng (MD4, AESCMAC, etc.). */
|
||||
private val patchesRegistered = AtomicBoolean(false)
|
||||
fun registerMissingAlgorithms() {
|
||||
if (patchesRegistered.compareAndSet(false, true)) {
|
||||
MissingAlgoProvider.register()
|
||||
}
|
||||
}
|
||||
}
|
||||
private val context: CIFSContext by lazy {
|
||||
registerMissingAlgorithms()
|
||||
val props = Properties().apply {
|
||||
// Force SMB 2.0.2 minimum — SMB1 is disabled on modern Windows
|
||||
setProperty("jcifs.smb.client.minVersion", "SMB202")
|
||||
@@ -33,7 +45,7 @@ class SmbTransport(
|
||||
// Shorter timeouts for Android
|
||||
setProperty("jcifs.smb.client.responseTimeout", "15000")
|
||||
setProperty("jcifs.smb.client.connTimeout", "10000")
|
||||
// Enable SMB signing for security (prevents tampering) — disable for legacy servers
|
||||
// SMB signing (disabled by default — most home servers don't support it)
|
||||
if (smbSigning) {
|
||||
setProperty("jcifs.smb.client.signingEnabled", "true")
|
||||
setProperty("jcifs.smb.client.encryptionEnabled", "true")
|
||||
@@ -47,7 +59,9 @@ class SmbTransport(
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a full SMB URL. If [path] is already a full URL, pass through. */
|
||||
private fun buildUrl(path: String): String {
|
||||
if (path.startsWith("smb://")) return path
|
||||
val cleanPath = path.trimStart('/')
|
||||
val sharePath = if (share.isNotEmpty()) "$share/$cleanPath" else cleanPath
|
||||
return "smb://$host/$sharePath"
|
||||
@@ -82,8 +96,18 @@ class SmbTransport(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-read with a fresh SmbFile handle to verify (jcifs-ng may have stale handle)
|
||||
val freshRemote = SmbFile(buildUrl(remotePath), context)
|
||||
val actualSize = freshRemote.length()
|
||||
Log.i(TAG, "upload done: $fileSize bytes local, $actualSize bytes on SMB")
|
||||
if (actualSize != fileSize) {
|
||||
Log.w(TAG, "upload size mismatch: local=$fileSize smb=$actualSize")
|
||||
// Try re-opening the output stream to flush any pending writes
|
||||
SmbFileOutputStream(remote).use { it.write(ByteArray(0)) }
|
||||
val retrySize = freshRemote.length()
|
||||
Log.w(TAG, "upload retry: smb=$retrySize bytes")
|
||||
}
|
||||
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
|
||||
Log.i(TAG, "upload $localPath -> ${buildUrl(remotePath)} ($fileSize bytes)")
|
||||
AppResult.Success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -184,15 +208,15 @@ class SmbTransport(
|
||||
} catch (e: SmbException) {
|
||||
// STATUS_OBJECT_NAME_COLLISION (0xC0000035): directory already exists — not an error
|
||||
if (e.ntStatus == 0xC0000035.toInt()) {
|
||||
AppResult.Success(Unit)
|
||||
AppResult.Success(Unit)
|
||||
} else {
|
||||
Log.e(TAG, "mkdirs failed: $remotePath — ${e.message}")
|
||||
Log.e(TAG, "mkdirs failed: $remotePath — ntStatus=0x${e.ntStatus.toString(16)} msg=${e.message} cause=${e.cause}")
|
||||
err(AppError.Remote("SMB 创建目录失败", "mkdirs", cause = e))
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "mkdirs failed: $remotePath — ${e.message}")
|
||||
Log.e(TAG, "mkdirs failed: $remotePath — ${e::class.java.name}: ${e.message} cause=${e.cause?.message}")
|
||||
err(AppError.Remote("SMB 创建目录失败", "mkdirs", cause = e))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/** UI-visible state driven by [ConfigViewModel]. */
|
||||
data class ConfigUiState(
|
||||
@@ -105,6 +106,8 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
private val _uiState = MutableStateFlow(ConfigUiState())
|
||||
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow()
|
||||
|
||||
/** Guards against concurrent [initResticRepo] calls. */
|
||||
private val initGuard = AtomicBoolean(false)
|
||||
/** Read config from file and refresh restic status. */
|
||||
fun load() {
|
||||
val config = BackupConfig.fromFile(configFile)
|
||||
@@ -159,6 +162,10 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
// ── Async restic operations ──────────────────────────────────────
|
||||
|
||||
fun initResticRepo(form: ResticForm) {
|
||||
if (!initGuard.compareAndSet(false, true)) {
|
||||
Log.w(TAG, "initResticRepo: already in progress, ignoring")
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "initResticRepo called: repo=${form.repo} backend=${form.backend}")
|
||||
|
||||
if (!prepareRestic()) {
|
||||
@@ -190,18 +197,19 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
if (result.isSuccess) {
|
||||
_operationEvents.emit(OperationEvent.InitCompleted)
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "仓库初始化成功: ${form.repo}", initButtonEnabled = true
|
||||
message = "仓库初始化成功: ${form.repo}"
|
||||
))}
|
||||
refreshResticStatus(form)
|
||||
} else {
|
||||
_operationEvents.emit(OperationEvent.InitFailed)
|
||||
Log.e(TAG, "initResticRepo failed: ${result.exceptionOrNull()?.message}")
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "初始化失败: ${result.exceptionOrNull()?.message}", initButtonEnabled = true
|
||||
message = "初始化失败: ${result.exceptionOrNull()?.message}"
|
||||
))}
|
||||
refreshResticStatus(form)
|
||||
}
|
||||
} finally {
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(initButtonEnabled = true)) }
|
||||
initGuard.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user