11 Commits

Author SHA1 Message Date
sakuradairong
1f3e1ceea8 release: v1.12
fix: HEAD /backup/config 响应 Content-Length 为 0,restic 以为 config 空文件
fix: 添加 RemoteTransport.fileSize() 方法,HEAD 返回实际文件大小
feat: SmbTransport/WebdavTransport 实现 fileSize
2026-06-06 01:07:12 +08:00
sakuradairong
3813f49a12 release: v1.11
fix: restic REST URI 含 repo 前缀 (/backup/config), 桥接器未剥离导致
     type/name 解析错误, remoteBase + URI 双重拼接产生路径嵌套

fix: 在 handleRequest 中剥离 repoPath 前缀后再解析 segments,
     使 type/name 指向正确的 restic REST API 资源 (config/keys/data/...)
     "backup" 段不会再被拼入 SMB 路径
2026-06-06 00:31:42 +08:00
sakuradairong
b2ea0c7960 release: v1.10
fix: config/blob GET handler 提前删文件导致 restic 读到零字节
fix: NanoHTTPD Response 在 handler 返回后才发送,finally 删除过早
fix: 改为 readBytes() 后关闭文件,再返回 InputStream
2026-06-06 00:25:20 +08:00
sakuradairong
058bf23465 release: v1.9
fix: streamBodyToFile 按 Content-Length 精确读取,防止 keep-alive 死锁
fix: NanoHTTPD inputStream 无 Content-Length 限制,copyTo 读到下一个请求
chore: bridge.start(0) 禁用 socket 超时(restic 密钥生成不限时)
chore: 移除 ConfigViewModel withTimeoutOrNull(由桥接器超时控制)
2026-06-06 00:18:09 +08:00
sakuradairong
7fec4c52a1 release: v1.8
fix: 桥接器 socket 超时 = 0(禁用),restic 密钥生成不限时
fix: 去掉应用层超时兜底,让 init 自然完成
feat: streamBodyToFile 添加耗时日志(可观察密钥生成耗时)
2026-06-06 00:01:23 +08:00
sakuradairong
32182b592e release: v1.7
chore: 桥接器超时 60s,应用层超时 60s(按用户反馈)
2026-06-05 23:55:25 +08:00
sakuradairong
bb7dc9a700 release: v1.6
fix: 彻底禁用桥接器 socket 超时(start(0)),restic 密钥生成在慢设备上可超过 5 分钟
fix: ConfigViewModel.initResticRepo 添加 15 分钟超时兜底
fix: SMB blob 上传校验大小一致性(重读远端文件验证)
fix: MissingAlgoProvider 合并 MD4 + AESCMAC 算法注入
fix: NanoHTTPD socket timeout = 0(无限超时)避免 blob 体读取中断
feat: ConfigViewModel initGuard 防重复初始化
feat: SMB 传输缓存复用避免跨桥接器认证重建
2026-06-05 23:53:28 +08:00
sakuradairong
b01569416d release: v1.5
- fix: 增加 NanoHTTPD 桥接器 socket 超时 10s→300s 修复 SMB 慢传输超时
- fix: streamBodyToFile 改用 Result<File>,报错时返回具体异常信息
- feat: SMB 传输缓存复用,避免跨桥接器 SMB 会话重建
- feat: MD4/AESCMAC 算法注入支持(jcifs-ng 兼容)
- feat: ResticRepoInit 退出码 1 区分仓库已存在和真实错误
- feat: ConfigViewModel initGuard 防重复初始化
- fix: SMB signing 默认关闭(兼容家庭 NAS)
- fix: SMB 上传后重读校验文件大小一致性
- fix: 仓库初始化成功后自动刷新状态
- build: 依赖 BouncyCastle bcprov(MD4)、jcifs-ng 排除 BouncyCastle
- build: ProGuard 保留 jcifs 反射调用类
- build: 签名配置修正 storeFile 路径 + v1/v2 签名启用
- refactor: ResticRestBridge 流式文件读取(避免 OOM)
2026-06-05 23:44:06 +08:00
sakuradairong
26823fcb6f fix: 修复 release 签名配置(移除 if 守卫使 storeFile 始终生效)
signingConfigs.release 的 storeFile/storePassword 之前放在 if (keystoreFile.exists()) 块内,
导致 Gradle 配置阶段有时跳过属性赋值,APK 构建后无 v1 签名(v2 签名正常工作)。
移除 if 守卫后签名配置始终完整设置。
2026-06-05 16:07:48 +08:00
sakuradairong
6f6549d897 chore: bump version to v1.4 (versionCode 5) 2026-06-05 15:58:39 +08:00
sakuradairong
c10505fc10 feat: APK 体积优化 v1.4 — R8 full mode + shrinkResources + 依赖裁剪
- 启用 release minifyEnabled + shrinkResources
- 排除 BouncyCastle PQC unused 文件(节省 1.18 MB)
- 排除 sardine-android 传递依赖 xpp3/stax(修复 R8 类型冲突)
- 添加 ProGuard keep 规则(kotlinx-serialization / NanoHTTPD / 数据类)
- 修正 NanoHTTPD keep 规则包路径(org.nanohttpd → fi.iki.elonen)
- 修正 AppError/AppResult keep 规则包路径(domain → backup)
- APK 体积: 25 MB → 11.8 MB(-52.8%)
- 更新 README:版本历史、体积优化说明、编译命令
2026-06-05 14:51:26 +08:00
12 changed files with 579 additions and 58 deletions

View File

@@ -9,6 +9,7 @@ Android 应用备份与恢复工具,集成 [restic](https://restic.net/) 实
- **并行备份/恢复** — 备份并发数 3Semaphore(3)),恢复并发数 2Semaphore(2)
- **存档完整性校验** — 备份后自动 zstd/gzip 校验数据归档
- **restic 增量去重** — 内建 `librestic.so`~24MB支持本地和远端仓库
- **构建体积优化** — Release APK 仅 11.8 MBProGuard/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 APKProGuard/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/`
## 使用说明

View File

@@ -26,8 +26,8 @@ android {
applicationId "com.example.androidbackupgui"
minSdk 24
targetSdk 34
versionCode 4
versionName "1.3"
versionCode 13
versionName "1.12"
}
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"

View File

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

View File

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

View File

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

View File

@@ -42,6 +42,8 @@ interface RemoteTransport {
suspend fun delete(remotePath: String): AppResult<Unit>
suspend fun exists(remotePath: String): AppResult<Boolean>
/** Get the size of a remote file in bytes. Returns [AppResult.Failure] if not found. */
suspend fun fileSize(remotePath: String): AppResult<Long>
companion object {

View File

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

View File

@@ -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. */

View File

@@ -4,9 +4,9 @@ import android.util.Log
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoHTTPD.IHTTPSession
import kotlinx.coroutines.runBlocking
import java.io.ByteArrayInputStream
import java.io.File
import java.util.UUID
/**
* NanoHTTPD-based REST bridge implementing the restic REST backend API.
*
@@ -14,10 +14,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 +61,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 +84,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 +122,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")
@@ -127,10 +169,15 @@ class ResticRestBridge(
val remotePath = "$remoteBase/config"
when (method) {
NanoHTTPD.Method.HEAD -> {
when (val result = transport.exists(remotePath)) {
when (val exists = transport.exists(remotePath)) {
is AppResult.Success -> {
if (result.data) {
newFixedLengthResponse(Response.Status.OK, "application/octet-stream", "")
if (exists.data) {
val sizeResult = transport.fileSize(remotePath)
val fileSize = if (sizeResult is AppResult.Success) sizeResult.data else 0L
newFixedLengthResponse(
Response.Status.OK, "application/octet-stream",
ByteArrayInputStream(ByteArray(0)), fileSize
)
} else {
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "")
}
@@ -145,8 +192,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 +204,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 +324,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 +351,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(

View File

@@ -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: $remotePathntStatus=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))
}
}
@@ -229,4 +253,17 @@ class SmbTransport(
err(AppError.Remote("SMB 检查失败", "exists", cause = e))
}
}
override suspend fun fileSize(remotePath: String): AppResult<Long> =
withContext(Dispatchers.IO) {
try {
val file = smbFile(remotePath)
if (!file.exists()) return@withContext err(AppError.Remote("文件不存在", "fileSize"))
AppResult.Success(file.length())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
err(AppError.Remote("SMB 获取文件大小失败", "fileSize", cause = e))
}
}
}

View File

@@ -180,4 +180,19 @@ class WebdavTransport(
err(AppError.Remote("WebDAV 检查失败", "exists", cause = e))
}
}
override suspend fun fileSize(remotePath: String): AppResult<Long> =
withContext(Dispatchers.IO) {
try {
val url = buildUrl(remotePath)
if (!sardine.exists(url)) return@withContext err(AppError.Remote("文件不存在", "fileSize"))
val resources = sardine.list(url)
val size = resources.firstOrNull()?.contentLength ?: 0L
AppResult.Success(size)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
err(AppError.Remote("WebDAV 获取文件大小失败", "fileSize", cause = e))
}
}
}

View File

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