Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f3e1ceea8 | ||
|
|
3813f49a12 | ||
|
|
b2ea0c7960 | ||
|
|
058bf23465 | ||
|
|
7fec4c52a1 | ||
|
|
32182b592e |
@@ -26,8 +26,8 @@ android {
|
||||
applicationId "com.example.androidbackupgui"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 7
|
||||
versionName "1.6"
|
||||
versionCode 13
|
||||
versionName "1.12"
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ class RestBridgeRunner {
|
||||
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(0)
|
||||
|
||||
@@ -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")
|
||||
@@ -110,13 +123,39 @@ class ResticRestBridge(
|
||||
* Returns the temp file (caller must delete).
|
||||
*/
|
||||
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) }
|
||||
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) {
|
||||
Log.w(TAG, "stream body to file failed", e)
|
||||
val elapsed = System.currentTimeMillis() - started
|
||||
Log.w(TAG, "streamBodyToFile failed after ${elapsed}ms", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
@@ -130,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", "")
|
||||
}
|
||||
@@ -148,7 +192,8 @@ class ResticRestBridge(
|
||||
try {
|
||||
when (transport.download(remotePath, tempFile.absolutePath)) {
|
||||
is AppResult.Success -> {
|
||||
newChunkedResponse(Response.Status.OK, "application/octet-stream", tempFile.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", ""
|
||||
@@ -279,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(
|
||||
|
||||
@@ -253,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@@ -190,21 +189,12 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_operationEvents.emit(OperationEvent.InitStarted)
|
||||
val result = withTimeoutOrNull(15 * 60 * 1000L) {
|
||||
ResticWrapper.init(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
}
|
||||
if (result == null) {
|
||||
_operationEvents.emit(OperationEvent.InitFailed)
|
||||
Log.w(TAG, "initResticRepo timed out after 15 minutes")
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "初始化超时(15分钟),请检查网络/SMB 服务器是否正常"
|
||||
))}
|
||||
refreshResticStatus(form)
|
||||
} else if (result.isSuccess) {
|
||||
val result = ResticWrapper.init(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
)
|
||||
if (result.isSuccess) {
|
||||
_operationEvents.emit(OperationEvent.InitCompleted)
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "仓库初始化成功: ${form.repo}"
|
||||
|
||||
Reference in New Issue
Block a user