Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2ea0c7960 | ||
|
|
058bf23465 | ||
|
|
7fec4c52a1 | ||
|
|
32182b592e | ||
|
|
bb7dc9a700 | ||
|
|
b01569416d | ||
|
|
26823fcb6f |
@@ -26,8 +26,8 @@ android {
|
||||
applicationId "com.example.androidbackupgui"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 5
|
||||
versionName "1.4"
|
||||
versionCode 11
|
||||
versionName "1.10"
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
@@ -37,21 +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 true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
if (file("release.keystore").exists()) {
|
||||
if (rootProject.file("app/release.keystore").exists()) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
@@ -97,7 +93,9 @@ 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("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'
|
||||
@@ -106,6 +104,8 @@ dependencies {
|
||||
|
||||
// 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"
|
||||
|
||||
7
app/proguard-rules.pro
vendored
7
app/proguard-rules.pro
vendored
@@ -49,3 +49,10 @@
|
||||
# --- 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)
|
||||
|
||||
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. */
|
||||
|
||||
@@ -109,13 +109,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 +174,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 +186,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 +306,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 +333,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