Files
android-backup-gui/silent-failure-review.md
sakuradairong 5faedd53af release: v1.13
- CRITICAL: 配置文件权限加固, 无障碍修复
- HIGH: CancellationException 透传 ×8, SMB/WebDAV Failure 修复, supervisorScope
- 构建: bind 127.0.0.1, allowBackup=false, CI test
- 安全: 签名密码加固, ResticRestBridge auth
- 死代码: 删除 MD4Provider, 3 个死方法, DataSizes, isFileNotFound, getAppLabel
- 修复: ResticCommandRunner NPE, MissingAlgoProvider 全局注册
- 网络: SMB/WebDAV 重试+退避, WebDAV Range 断点续传
- 稳定性: onDestroyView null-safety, isArchiveSafe symlink 误杀修复, WebDAV 超时配置
2026-06-06 13:09:23 +08:00

19 KiB
Raw Permalink Blame History

静默失败审查报告 — android-backup-gui

审查日期: 2026-06-06 审查范围: 37 个 Kotlin 源文件 已排除: memory://root 已知的 7 个待处理项


严重程度分级

  • CRITICAL: 数据静默损坏或丢失,用户无法感知
  • HIGH: 错误被吞没,导致后续操作基于错误假设继续
  • MEDIUM: 错误被吞没但影响范围有限,或仅影响辅助功能
  • LOW: 微小错误处理缺失,实际影响小

发现清单

F1 [HIGH] — SMB 上传大小不匹配不报告错误

文件: SmbTransport.kt:103-109 类型: 未检查的返回值 / 静默数据损坏

if (actualSize != fileSize) {
    Log.w(TAG, "upload size mismatch: local=$fileSize smb=$actualSize")
    SmbFileOutputStream(remote).use { it.write(ByteArray(0)) }
    val retrySize = freshRemote.length()
    Log.w(TAG, "upload retry: smb=$retrySize bytes")
}
// 继续返回 Success(Unit)

即使 SMB 端实际存储的字节数与本地不一致,upload() 仍返回 AppResult.Success(Unit)。写入零字节空数组的"修复"尝试没有验证效果。如果 SMB 服务器写入缓存有问题或磁盘空间不足restic blob 数据可能部分损坏,而上层调用者 (RestBridgeRunner) 不知道。

建议: 当 actualSize != fileSize 时,应返回 err(AppError.Remote("SMB 上传大小不匹配: local=$fileSize vs smb=$actualSize", "upload"))


F2 [HIGH] — backupUserData 全失败时返回成功

文件: BackupOperation.kt:255-257 类型: 错误替换/空回退

if (!archiveCreated) {
    Log.w(TAG, "backupUserData: $packageName all methods failed ...")
    return true  // 返回成功!
}

当三种数据备份方法全部失败时目录不存在、权限不足、tar 不可用),函数返回 true。上层调用者 (BackupOperation.backupApps line 131) 看到 true 就认为数据备份成功,累加 successAtomic,用户看到的报告就是"成功"。应用的用户数据被静默跳过。

建议: 改为 return false 让调用者知道数据备份实际失败。如果需要容错(某些应用确实没有数据目录),应在 backupUserData 外部判断,或返回区分"跳过"和"失败"的信号。


F3 [HIGH] — CancellationException 被空 catch 吞没

文件: ResticBackup.kt:55-58, ResticBackup.kt:73-77, ResticBackup.kt:117-120, ResticBackup.kt:130-134 类型: 异步错误丢失

try {
    val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
    if (progress.messageType == "status") emit(progress)
} catch (_: Exception) { }

catch (_: Exception) 会捕获 kotlinx.coroutines.CancellationException。如果协程在 JSON 解析期间被取消,取消信号被吞没,进度回调继续运行。虽然在 runResticStreaming/runResticWithStdin 内部也有协程活跃检查(!coroutineContext.isActive),但取消信号仍可能延迟或丢失。

建议: 在空 catch 前加 catch (e: CancellationException) { throw e },或改用 catch (e: Exception) { if (e is CancellationException) throw e }


F4 [HIGH] — WebDAV mkdirs 完全失败时仍返回成功

文件: WebdavTransport.kt:153-155 类型: 错误替换

} catch (e: Exception) {
    Log.w(TAG, "mkdirs failed: $remotePath${e.message}")
    AppResult.Success(Unit)  // best-effort
}

即使所有目录层级都无法创建,该方法返回 AppResult.Success(Unit)。注释说"upload will fail if dir can't be created",但上传可能在更深层的操作上以不同的错误信息失败(如"permission denied" vs "directory not found"),使诊断更加困难。上层调用者无法区分"目录已存在"和"完全无法创建"。

建议: 仅在确定目录确实存在时返回 Success如 SMB 实现中检测 STATUS_OBJECT_NAME_COLLISION)。对所有其他异常应返回 err(AppError.Remote(...))


F5 [MEDIUM] — WifiManager.restore 始终返回 true

文件: WifiManager.kt:54-85 类型: 错误替换

整个 restore() 方法始终返回 trueline 84即使

  • findWifiConfigPath() 返回 null 且 fallback 路径无法创建目录line 63-64 返回 false但被统一 return@withContext false 处理... 等等这里 line 84 是最后一行)
    • 实际上 line 63 return@withContext false 确实会提前返回。但如果成功执行到 line 84无论如何都返回 true。中间 cpchownchmod 的失败仅被记录日志,不通知调用者。

建议: cpchmod/chown 失败时应返回 false。当前 RestoreFragment 中 WifiManager.restore(dir) 的返回值没有被使用,但接口应该诚实。


F6 [MEDIUM] — ResticWrapper.getLatestSnapshotAppDetails 静默返回 null

文件: ResticWrapper.kt:270-275, ResticWrapper.kt:288 类型: 空回退

is AppResult.Failure -> {
    Log.w(TAG, "getLatestSnapshotAppDetails: listSnapshots failed: ...")
    null
}
// 和
is AppResult.Failure -> return@withContext null

listSnapshots()dump() 失败时返回 null。调用者 (BackupFragment.kt:228) 仅检查 snapshotApps != null,看到 null 就跳过累积快照逻辑。用户不知道仓库存在但无法读取——也可能是仓库密码错误、网络问题或权限问题。但此行为在 API 文档中有意说明,且后续备份仍能工作,只是失去了累积合并能力。

建议: 考虑返回 AppResult<Map<String, SnapshotAppInfo>?> 以区分"无快照"和"读取失败"。或者增加 UI 通知。


F7 [MEDIUM] — parseAppDetailsJson 捕获所有异常

文件: ResticWrapper.kt:315-317 类型: 被吞没的异常

} catch (_: Exception) {
    Log.w(TAG, "parseAppDetailsJson: failed to parse JSON")
}

捕获所有 Exception 类型(包括 CancellationExceptionOutOfMemoryError 等)。虽然当前函数在 Dispatchers.IO 上下文外的同步路径调用,但应该缩小异常范围。

建议: 改为 catch (e: org.json.JSONException)


F8 [MEDIUM] — StreamingBackup mkfifo 失败不报告

文件: StreamingBackup.kt:50 类型: 未检查的返回值

RootShell.exec("mkfifo '${fifo.absolutePath.shellEscape()}'")

mkfifo 的执行结果完全被忽略。如果 mkfifo 失败例如文件系统只读、磁盘满FIFO 文件不存在,后续 restic backup --stdin 会以模糊的错误失败。StreamingBackup.prepareStreaming 返回的 StreamingResult 将包含无效的 FIFO 路径。调用者在 BackupFragment.kt:484 直接使用结果,没有验证 FIFO 是否创建成功。

建议: 检查结果并抛出异常或返回失败信号。


F9 [MEDIUM] — BackupFragment 中 restore 操作结果被忽略

文件: ui/BackupFragment.kt:274-284 类型: 未检查的返回值

ResticWrapper.restore(
    repoPath = config.resticRepo,
    password = config.resticPassword,
    snapshotId = latestSnap.shortId,
    targetPath = backupRoot.absolutePath,
    ...
)

在累积备份流程中,从 restic 仓库恢复最新快照到本地暂存目录的结果完全被忽略。如果恢复失败(例如密码错误、网络中断),backupRoot 目录可能不完整,但备份操作继续执行。后续 BackupOperation.backupApps 可能会基于不完整的文件结构工作。

建议: 检查 restore()AppResult,如果失败则终止备份流程并通知用户。


F10 [MEDIUM] — StreamingBackup.launchDataProducer 的 tar 失败仅记录日志

文件: StreamingBackup.kt:116-118 类型: 被吞没的异常

if (!result.isSuccess) {
    Log.w(TAG, "Data backup failed for $pkgName: ${result.error}")
}

单个应用的 tar 数据备份失败时仅记录日志,继续下一个应用。调用者 (BackupFragment.kt:534) 通过 producerJob.await() 等待完成,但该函数总是返回 true除非协程被取消。这意味着即使某些应用的数据完全没有备份调用者也认为一切正常。restic 对缺失数据无法感知——它只归档了 FIFO 中收到的内容。

建议: 收集失败列表并通过返回值或回调通知调用者。


F11 [MEDIUM] — RestBridgeRunner 中未识别的后端静默穿透

文件: RestBridgeRunner.kt:61 类型: 空回退

val t = transportFactory(...)
    ?: return block(repoPath)

RemoteTransport.create() 返回 null(未知 backend代码直接调用 block(repoPath),其中 repoPath 是原始路径字符串而不是桥接 URL。restic 会收到一个可能无效的仓库 URL产生令人困惑的错误"repository doesn't exist" 而不是"未知后端类型")。

建议: 至少记录一个错误,或抛出异常说明后端类型不支持。


F12 [MEDIUM] — SMB listFiles 在无权限时静默返回空列表

文件: SmbTransport.kt:165-186 类型: 空回退

val entries = dir.listFiles()
    ?.map { f -> ... }
    ?: emptyList()

SmbFile.listFiles() 在 SMB 权限不足时可能返回 null。此时 ?: emptyList() 将静默返回空列表。调用者可能认为路径是空的而不是没有读取权限。SMB 协议可以在 SmbException 中返回具体的 ntStatus 错误,但这里的 null 合并将错误掩盖了。

建议: 在 else 分支或 catch 中检查文件是否确实存在,如果存在但 listFiles 返回 null应返回错误而非空列表。


F13 [MEDIUM] — backupPermissions 静默跳过

文件: BackupOperation.kt:349-354 类型: 被吞没的异常

private suspend fun backupPermissions(packageName: String, appDir: File) {
    val result = RootShell.exec("dumpsys package ...")
    if (result.output.isNotBlank()) {
        File(appDir, "permissions.txt").writeText(result.output)
    }
}

如果 dumpsys package 命令失败或输出为空,权限备份静默跳过。backupApps 在 line 163 调用此函数时不检查结果,也不记录错误。恢复时将没有权限文件,应用以默认权限运行。

建议: 至少在 dumpsys 命令失败时记录日志。考虑返回 Boolean 让调用者知晓。


F14 [MEDIUM] — backupSsaid 静默跳过

文件: BackupOperation.kt:331-347 类型: 被吞没的异常

private suspend fun backupSsaid(packageName: String, appDir: File, userId: String) {
    val result = RootShell.exec("cat '$ssaidFile' 2>/dev/null")
    if (!result.isSuccess || result.output.isBlank()) return
    // ...
}

如果 XML 文件无法读取或解析失败SSAID 备份完全静默跳过。SSAID 是 Google 广告标识符,丢失后用户可能收到新的 ID。

建议: 在 cat 命令失败时记录警告日志。


F15 [MEDIUM] — initResticRepo 使用 exceptionOrNull 可能导致 null 显示

文件: ui/ConfigViewModel.kt:205-207 类型: 错误替换

Log.e(TAG, "initResticRepo failed: ${result.exceptionOrNull()?.message}")
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
    message = "初始化失败: ${result.exceptionOrNull()?.message}"
))}

AppResult.exceptionOrNull() 创建一个新的 RuntimeException,如果原始 AppError.message 为 null例如 AppError.Restic("", -1, "")),用户将看到 "初始化失败: null"。

建议: 使用 ${result.errorOrNull()?.message ?: "未知错误"}


F16 [MEDIUM] — RestBridgeRunner 中临时文件删除结果未检查

文件: RestBridgeRunner.kt:85-88 类型: 资源泄露

val blobs = cacheDir.listFiles { f -> f.name.startsWith("restic_blob_") }
if (blobs != null) {
    for (f in blobs) f.delete()
}

临时 blob 文件删除的结果未检查,且 listFiles 筛选器可能遗漏子目录中的临时文件(如 ResticRestBridgecacheDir 中创建 restic_blob_* 文件)。随着操作频繁进行,可能累积未清理的临时文件。

建议: 使用 f.delete() 的返回值进行日志记录,并考虑递归清理。


F17 [LOW] — RootShell.ensureSession 静默返回 false

文件: root/RootShell.kt:63-67 类型: 被吞没的异常

suspend fun ensureSession(): Boolean = withContext(Dispatchers.IO) {
    try {
        Shell.getShell().isRoot
    } catch (_: Exception) { false }
}

如果 Shell.getShell() 抛出任何异常(包括 NullPointerExceptionRuntimeException),静默返回 false。调用者无法区分"没有 root 权限"和"libsu 未初始化或其他错误"。

建议: 记录异常。可以考虑区分不同类型的失败。


F18 [LOW] — AppScanner 多项查询静默失败

文件: AppScanner.kt:41,53,96 类型: 空回退

if (!result.isSuccess) return@withContext emptyList()

scanThirdPartyscanSystemgetApkPaths 在 shell 命令失败时返回空列表。如果 pm list packages 因为 root 权限临时问题失败,用户看到的应用列表为空,但没有任何错误提示。

建议: 在 UI 层调用前检查返回的空列表并显示适当消息(已在 BackupFragment.scanApps() 中捕获异常,但 shell 层面的失败可能被漏过)。


F19 [LOW] — backupUserData tar 命令可能静默失败

文件: BackupOperation.kt:228 类型: 未检查的返回值

val dirs = dataPaths.filter { RootShell.exec("test -d '${it.shellEscape()}'").isSuccess }.toMutableList()

test -d 在 nsenter namespace 切换后可能对某些路径返回假阴性。如果所有 test -d 都失败,dirs 为空列表,代码会转到 else 分支line 234-238尝试直接运行 tar而 tar 也会因为没有源路径而静默失败或产生空归档。此时 archiveCreated 保持 false进入 line 255 的 fallback 处理——但这个 fallback 返回 true见 F2

建议: 如果 dirs 为空且 tar 直接执行也未产生输出,应返回明确的失败信号。


F20 [LOW] — WifiManager.backup 结果未在 BackupOperation 中检查

文件: ui/BackupFragment.kt:310 类型: 未检查的返回值

WifiManager.backup(File(result.outputDir))

WiFi 配置备份的结果完全被忽略。如果 WiFi 备份失败,用户不会收到任何通知。WifiManager.backup() 可以返回 null(失败时),但调用者没有使用返回值。

建议: 至少记录结果,考虑在最终摘要中显示 WiFi 备份状态。


F21 [LOW] — estimateBackupSize 忽略 du 错误

文件: ui/BackupFragment.kt:440-449 类型: 未检查的返回值

val result = RootShell.exec("du -sb /data/data/$pkgEsc 2>/dev/null | cut -f1")
val size = result.output.trim().toLongOrNull() ?: 0L

如果 du 命令失败、输出为空或解析失败,该应用的估计大小为 0。最终的空间估算可能严重偏低仅用于判断是否需要流式备份可能导致本应触发流式备份的大数据集使用暂存模式。

建议: 考虑使用保守的默认值或根据应用大小粗略估算。


F22 [LOW] — BackupOperation 中 chmod 结果未检查

文件: BackupOperation.kt:173 类型: 未检查的返回值

RootShell.exec("chmod -R 0755 '${backupRoot.absolutePath}'")

备份完成后设置目录权限的结果未检查。虽然不影响备份数据的完整性,但如果 chmod 失败,后续读取备份的用户可能会遇到权限问题。

建议: 至少记录 chmod 失败日志。


F23 [LOW] — BackupFragment 中前台服务启动异常被吞没

文件: ui/BackupFragment.kt:193-195 类型: 被吞没的异常

try {
    ContextCompat.startForegroundService(requireContext(), serviceIntent)
} catch (_: Exception) {}

如果前台服务启动失败(例如缺少权限、应用在后台),异常被完全吞没。备份操作仍然继续,但进程可能被 Android 杀死。

建议: 记录异常,考虑通知用户服务启动失败。


F24 [LOW] — ConfigFragment 中 OperationEvent 的 InitFailed/PruneFailed 不显示错误详情

文件: ui/ConfigFragment.kt:157-159 类型: 错误替换

is OperationEvent.InitFailed -> {
    Log.d(TAG, "init failed")
    Snackbar.make(binding.root, "仓库初始化失败", Snackbar.LENGTH_SHORT).show()
}

InitFailed/PruneFailed 事件不携带错误详情,用户只看到"初始化失败"/"清理失败",不知道具体原因。实际错误消息在 ViewModel 的 resticStatus.message 中已经设置,但 UI 没有在 snackbar 中使用它。

建议: 从 ViewModel 状态读取错误详情并在 snackbar 中显示,或让 OperationEvent 携带错误消息。


F25 [LOW] — RestBridgeRunner 中缓存传输不被清理

文件: RestBridgeRunner.kt:58-63 类型: 资源泄露

if (cachedTransportKey != key) {
    cachedTransport?.let { Log.d(TAG, "discarding stale cached transport") }
    val t = transportFactory(...)
    ...
    cachedTransport = t
    cachedTransportKey = key
}

当缓存键变化时,旧的 cachedTransportSMB 会话或 WebDAV client被直接丢弃而不关闭。对于 SmbTransport,内部的 CIFSContext 和 jcifs-ng 连接可能保持打开,直到 GC 触发 finalizer。对于 WebdavTransportOkHttp 客户端可能保持连接池和线程。

建议: 如果 RemoteTransport 接口添加 close() 方法,在替换缓存时调用。


分类统计

严重程度 数量 关键文件
HIGH 4 SmbTransport, BackupOperation, ResticBackup, WebdavTransport
MEDIUM 12 ResticWrapper(2), StreamingBackup(2), BackupFragment, WifiManager, RestBridgeRunner, SmbTransport, BackupOperation(2), ConfigViewModel, AppScanner
LOW 9 RootShell, AppScanner(3), BackupFragment(3), BackupOperation, ConfigFragment, RestBridgeRunner

发现总数: 25


总结与优先修复建议

必须修复 (HIGH)

  1. F1 (SmbTransport.kt:103-109) — SMB 上传后大小校验失败应返回错误,而非静默继续
  2. F2 (BackupOperation.kt:255-257) — backupUserData 全方式失败时返回 true 是在告知上层"数据已备份"
  3. F3 (ResticBackup.kt:55-58 等) — 进度回调中的空 catch 吞没 CancellationException,需添加重新抛出
  4. F4 (WebdavTransport.kt:153-155) — mkdirs 完全失败返回 Success 是错误替换

高优先级 (MEDIUM)

  • F10StreamingBackup.launchDataProducer 不传播 tar 错误
  • F12SmbTransport listFiles 返回 null 时可能是权限问题
  • F13/F14backupPermissions/backupSsaid 静默跳过
  • F8StreamingBackup.mkfifo 结果未检查

建议

整个代码库中使用 catch (_: Exception) 的模式需要系统性审查:应在所有协程 lambda 中的空 catch 前加 catch (e: CancellationException) { throw e }。关键入口点(如 ResticBackup.kt:58)已有 CancellationException 被吞没的问题。