3 Commits
v1.7 ... v1.10

Author SHA1 Message Date
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
4 changed files with 43 additions and 26 deletions

View File

@@ -26,8 +26,8 @@ android {
applicationId "com.example.androidbackupgui"
minSdk 24
targetSdk 34
versionCode 8
versionName "1.7"
versionCode 11
versionName "1.10"
}
buildFeatures {
viewBinding true

View File

@@ -68,7 +68,7 @@ class RestBridgeRunner {
val bridge = ResticRestBridge(transport, remoteBase, cacheDir)
try {
bridge.start(60_000)
bridge.start(0)
val port = bridge.listeningPort
if (port < 0) {
throw IllegalStateException("REST bridge failed to bind a port")

View File

@@ -110,13 +110,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)
}
}
@@ -148,7 +174,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 +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(

View File

@@ -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(60_000L) {
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 1 minute")
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "初始化超时1分钟请检查网络/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}"