Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e98e0f78e | ||
|
|
922a8f0381 | ||
|
|
5fcf261025 |
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **android-backup-gui** (922 symbols, 2334 relationships, 79 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **android-backup-gui** (1295 symbols, 3535 relationships, 112 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **android-backup-gui** (922 symbols, 2334 relationships, 79 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **android-backup-gui** (1295 symbols, 3535 relationships, 112 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ android {
|
||||
applicationId "com.example.androidbackupgui"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 3
|
||||
versionName "1.2"
|
||||
versionCode 4
|
||||
versionName "1.3"
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
@@ -95,6 +95,7 @@ dependencies {
|
||||
|
||||
// root shell via libsu (Magisk/KernelSU/APatch)
|
||||
implementation 'com.github.topjohnwu:libsu:6.0.0'
|
||||
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"
|
||||
testImplementation "io.kotest:kotest-property:5.9.1"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import com.example.androidbackupgui.backup.ResticWrapper.SnapshotAppInfo
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import android.util.Log
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
@@ -7,6 +8,7 @@ import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
@@ -48,7 +50,11 @@ object BackupOperation {
|
||||
* @param config backup configuration
|
||||
* @param outputDir root output directory
|
||||
* @param userId Android user ID (0, 999, etc.)
|
||||
* @param onProgress callback for UI updates
|
||||
* @param includePkgs if non-empty, only backup apps whose package name is in this set;
|
||||
* metadata (app_details.json, appList.txt) is still generated for all [apps].
|
||||
* @param legacyApps metadata from a previous snapshot used to populate app_details.json
|
||||
* for apps not in [apps] (keeps them in the cumulative snapshot record
|
||||
* without requiring re-scans of possibly-uninstalled apps).
|
||||
*/
|
||||
suspend fun backupApps(
|
||||
context: android.content.Context,
|
||||
@@ -56,6 +62,9 @@ object BackupOperation {
|
||||
config: BackupConfig,
|
||||
outputDir: File,
|
||||
userId: String = "0",
|
||||
noDataBackup: Set<String> = emptySet(),
|
||||
includePkgs: Set<String> = emptySet(),
|
||||
legacyApps: Map<String, SnapshotAppInfo>? = null,
|
||||
onProgress: suspend (BackupProgress) -> Unit = {}
|
||||
): BackupResult = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (BackupProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
@@ -66,28 +75,31 @@ object BackupOperation {
|
||||
backupRoot.mkdirs()
|
||||
LogUtil.i(TAG, "backupApps: starting backup of ${apps.size} apps to ${backupRoot.absolutePath}")
|
||||
|
||||
// Write app list
|
||||
// Write app list — includes ALL packages in [apps] (selected + legacy from snapshot)
|
||||
val appListFile = File(backupRoot, "appList.txt")
|
||||
appListFile.writeText(apps.joinToString("\n") { it.packageName.value })
|
||||
|
||||
// Write metadata JSON
|
||||
// Write metadata JSON — fresh metadata for selected apps, legacy for historical apps
|
||||
val metaFile = File(backupRoot, "app_details.json")
|
||||
metaFile.writeText(buildAppDetailsJson(apps))
|
||||
metaFile.writeText(buildAppDetailsJson(apps, legacyApps))
|
||||
|
||||
val backupTargets = if (includePkgs.isEmpty()) apps else apps.filter { it.packageName.value in includePkgs }
|
||||
val totalCount = backupTargets.size
|
||||
LogUtil.i(TAG, "backupApps: includePkgs=${includePkgs.size} targets=$totalCount")
|
||||
val semaphore = Semaphore(3)
|
||||
val successAtomic = AtomicInteger(0)
|
||||
val failAtomic = AtomicInteger(0)
|
||||
val skippedAtomic = AtomicInteger(0)
|
||||
|
||||
coroutineScope {
|
||||
apps.mapIndexed { index, app ->
|
||||
backupTargets.mapIndexed { index, app ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
ensureActive()
|
||||
val appDir = File(backupRoot, app.packageName.value)
|
||||
appDir.mkdirs()
|
||||
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "apk", "正在备份 APK…"))
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "apk", "正在备份 APK…"))
|
||||
|
||||
// 1. Backup APK
|
||||
val paths = AppScanner.getApkPaths(app.packageName.value)
|
||||
@@ -100,23 +112,27 @@ object BackupOperation {
|
||||
|
||||
if (!apkOk) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "done", "APK 备份失败"))
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "APK 备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
|
||||
// 1.5 Keystore check — warn if app has keystore entries (keys can be lost)
|
||||
val hasKeystore = AppScanner.hasKeystore(app.packageName.value)
|
||||
if (hasKeystore) {
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "data", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失"))
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失"))
|
||||
}
|
||||
|
||||
// 2. Backup user data (if configured)
|
||||
if (config.backupMode == 1 && config.backupUserData == 1) {
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "data", "正在备份数据…"))
|
||||
if (!backupUserData(context, app.packageName.value, appDir, userId, config.compressionMethod)) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "done", "数据备份失败"))
|
||||
return@withPermit
|
||||
if (app.packageName.value in noDataBackup) {
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "跳过数据备份(已排除)"))
|
||||
} else {
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "data", "正在备份数据…"))
|
||||
if (!backupUserData(context, app.packageName.value, appDir, userId, config.compressionMethod)) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "数据备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,17 +140,17 @@ object BackupOperation {
|
||||
if (config.backupMode == 1 && config.backupObbData == 1) {
|
||||
val hasObb = AppScanner.hasObbData(app.packageName.value)
|
||||
if (hasObb) {
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "obb", "正在备份 OBB…"))
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "obb", "正在备份 OBB…"))
|
||||
if (!backupObb(app.packageName.value, appDir, config.compressionMethod)) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "done", "OBB 备份失败"))
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "OBB 备份失败"))
|
||||
return@withPermit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Backup SSAID
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "ssaid", "正在备份 SSAID…"))
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "ssaid", "正在备份 SSAID…"))
|
||||
backupSsaid(app.packageName.value, appDir, userId)
|
||||
|
||||
// 4.5 Backup app icon
|
||||
@@ -147,7 +163,7 @@ object BackupOperation {
|
||||
backupPermissions(app.packageName.value, appDir)
|
||||
|
||||
successAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, apps.size, app.packageName.value, "done", "完成"))
|
||||
emit(BackupProgress(index + 1, totalCount, app.packageName.value, "done", "完成"))
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
@@ -337,14 +353,36 @@ object BackupOperation {
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildAppDetailsJson(apps: List<AppInfo>): String {
|
||||
internal suspend fun buildAppDetailsJson(
|
||||
apps: List<AppInfo>,
|
||||
legacyApps: Map<String, SnapshotAppInfo>? = null
|
||||
): String {
|
||||
val root = JSONObject()
|
||||
// Generate fresh metadata for apps in the current app list
|
||||
for (app in apps) {
|
||||
val entry = JSONObject()
|
||||
entry.put("label", app.label)
|
||||
entry.put("isSystem", app.isSystem)
|
||||
// Record APK file sizes for change detection in incremental backup
|
||||
val paths = AppScanner.getApkPaths(app.packageName.value)
|
||||
val sizes = paths.map { path ->
|
||||
val result = RootShell.exec("stat -c%s '${path.shellEscape()}'")
|
||||
if (result.isSuccess) result.output.trim().toLongOrNull() ?: 0L else 0L
|
||||
}
|
||||
entry.put("apkSizes", JSONArray(sizes))
|
||||
root.put(app.packageName.value, entry)
|
||||
}
|
||||
// Include legacy apps not in current app list with preserved metadata
|
||||
val legacyMap = legacyApps ?: emptyMap()
|
||||
for ((pkg, legacy) in legacyMap) {
|
||||
if (!root.has(pkg)) {
|
||||
val entry = JSONObject()
|
||||
entry.put("label", legacy.label)
|
||||
entry.put("isSystem", legacy.isSystem)
|
||||
entry.put("apkSizes", JSONArray(legacy.apkSizes))
|
||||
root.put(pkg, entry)
|
||||
}
|
||||
}
|
||||
return root.toString(2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Manages remote transport lifecycle (SMB/WebDAV) and local temp repo sync.
|
||||
*
|
||||
* For SMB/WebDAV backends, restic runs against a local temp directory;
|
||||
* [RemoteTransport] syncs files to/from the remote backend.
|
||||
*
|
||||
* All sync operations are serialized via [repoSyncMutex] so concurrent
|
||||
* operations don't corrupt the local temp repo.
|
||||
*/
|
||||
class RemoteSyncManager {
|
||||
|
||||
private sealed interface SyncEvent {
|
||||
data class Phase(val progress: RemoteTransport.TransferProgress) : SyncEvent
|
||||
data class Bytes(val progress: RemoteTransport.ByteProgress) : SyncEvent
|
||||
}
|
||||
|
||||
private val TAG = "ResticWrapper"
|
||||
|
||||
/** Local temp directory used as restic repo for SMB/WebDAV backends. */
|
||||
@Volatile
|
||||
var tempRepoDir: String = ""
|
||||
|
||||
/** Domain for SMB NTLM authentication. */
|
||||
@Volatile
|
||||
var backendDomain: String = ""
|
||||
|
||||
// ── Transport cache ──────────────────────────────────
|
||||
@Volatile private var transport: RemoteTransport? = null
|
||||
private var transportConfigKey: String = ""
|
||||
private val transportLock = Any()
|
||||
|
||||
/** Serializes access to tempRepoDir so concurrent operations don't corrupt each other. */
|
||||
private val repoSyncMutex = Mutex()
|
||||
|
||||
// ── Transport lifecycle ──────────────────────────────
|
||||
|
||||
private fun ensureTransport(
|
||||
backend: String, url: String, user: String, pass: String, share: String, repoPath: String
|
||||
): RemoteTransport? = synchronized(transportLock) {
|
||||
val key = "$backend|$url|$user|${pass.hashCode()}|$share|$backendDomain|$repoPath"
|
||||
if (key != transportConfigKey || transport == null) {
|
||||
transport?.let { Log.i(TAG, "transport config changed, recreating") }
|
||||
// Clear local temp repo when backend config changes so
|
||||
// syncFromRemote downloads fresh data from the new backend
|
||||
if (transportConfigKey.isNotEmpty() && tempRepoDir.isNotEmpty()) {
|
||||
val dir = File(tempRepoDir)
|
||||
val deleted = dir.deleteRecursively()
|
||||
Log.i(TAG, "cleared local temp repo: $tempRepoDir (deleted=$deleted)")
|
||||
dir.mkdirs()
|
||||
}
|
||||
transport = RemoteTransport.create(backend, url, user, pass, share, backendDomain)
|
||||
if (transport != null) {
|
||||
transportConfigKey = key
|
||||
Log.i(TAG, "transport created: $backend @ $url repo=$repoPath domain=$backendDomain")
|
||||
} else {
|
||||
Log.e(TAG, "transport creation failed for backend=$backend url=$url")
|
||||
}
|
||||
}
|
||||
return transport
|
||||
}
|
||||
|
||||
// ── Temp dir lifecycle ───────────────────────────────
|
||||
|
||||
/** Clean up local temp repo and cache directories. */
|
||||
private fun cleanupTempDirs() {
|
||||
if (tempRepoDir.isEmpty()) return
|
||||
try {
|
||||
val repoDir = File(tempRepoDir)
|
||||
if (repoDir.exists()) {
|
||||
val deleted = repoDir.deleteRecursively()
|
||||
Log.i(TAG, "cleanupTempDirs: deleted $tempRepoDir ($deleted)")
|
||||
}
|
||||
val cacheDir = File(tempRepoDir.substringBeforeLast("/") + "/restic_cache")
|
||||
if (cacheDir.exists()) {
|
||||
val deleted = cacheDir.deleteRecursively()
|
||||
Log.i(TAG, "cleanupTempDirs: deleted cache $cacheDir ($deleted)")
|
||||
}
|
||||
val tmpDir = File(tempRepoDir.substringBeforeLast("/") + "/restic_tmp")
|
||||
if (tmpDir.exists()) {
|
||||
val deleted = tmpDir.deleteRecursively()
|
||||
Log.i(TAG, "cleanupTempDirs: deleted tmp $tmpDir ($deleted)")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "cleanupTempDirs failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** True if [tempRepoDir] already contains an initialized restic repository (has a config file). */
|
||||
private fun isLocalRepoPopulated(): Boolean {
|
||||
if (tempRepoDir.isEmpty()) return false
|
||||
return File(tempRepoDir, "config").isFile
|
||||
}
|
||||
|
||||
// ── Sync engine ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute [action] with remote repo synced before/after as needed.
|
||||
* For local/rest-server backends, executes [action] directly without sync.
|
||||
* Protected by [repoSyncMutex] so concurrent operations don't corrupt tempRepoDir.
|
||||
*
|
||||
* Cleanup strategy:
|
||||
* - Write ops (needsUpload=true): cleanup only on successful sync to remote.
|
||||
* On syncToRemote failure the local repo is preserved so the next
|
||||
* operation can retry — destroying it would lose the just-created snapshot.
|
||||
* - Read-only ops (needsUpload=false): keep local cache for subsequent operations.
|
||||
* - Read-only ops skip download entirely if local repo is already populated.
|
||||
*/
|
||||
suspend fun <T> withRemoteSync(
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
repoPath: String,
|
||||
needsDownload: Boolean,
|
||||
needsUpload: Boolean,
|
||||
onProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
action: suspend () -> AppResult<T>
|
||||
): AppResult<T> {
|
||||
if (backend != "smb" && backend != "webdav") return action()
|
||||
|
||||
return repoSyncMutex.withLock {
|
||||
coroutineScope {
|
||||
var shouldCleanup = false
|
||||
var lastByteEmitMs = 0L
|
||||
val progressChannel = Channel<SyncEvent>(CONFLATED)
|
||||
val progressJob = launch(Dispatchers.Main) {
|
||||
for (event in progressChannel) {
|
||||
when (event) {
|
||||
is SyncEvent.Phase -> onProgress(event.progress)
|
||||
is SyncEvent.Bytes -> {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastByteEmitMs >= 50) {
|
||||
onByteProgress(event.progress)
|
||||
lastByteEmitMs = now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val t = ensureTransport(backend, backendUrl, backendUser, backendPass, backendShare, repoPath)
|
||||
?: return@coroutineScope err(AppError.Remote("Failed to create transport for backend: $backend", "connecting"))
|
||||
|
||||
val localDir = File(tempRepoDir)
|
||||
|
||||
val emitProgress: suspend (RemoteTransport.TransferProgress) -> Unit = { p ->
|
||||
progressChannel.send(SyncEvent.Phase(p))
|
||||
}
|
||||
val emitByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit = { p ->
|
||||
progressChannel.send(SyncEvent.Bytes(p))
|
||||
}
|
||||
|
||||
// Write ops always download to avoid overwriting remote changes.
|
||||
// Read-only ops skip download if local repo is already present.
|
||||
val actualDownload = needsDownload && (needsUpload || !isLocalRepoPopulated())
|
||||
if (actualDownload) {
|
||||
Log.i(TAG, "syncFromRemote start: $repoPath -> $tempRepoDir")
|
||||
val syncResult = RemoteTransport.syncFromRemote(t, localDir, repoPath, emitProgress, emitByteProgress)
|
||||
if (syncResult.isFailure) {
|
||||
shouldCleanup = true
|
||||
Log.e(TAG, "syncFromRemote FAILED: ${syncResult.exceptionOrNull()?.message}")
|
||||
return@coroutineScope err(AppError.Remote("syncFromRemote failed: ${syncResult.exceptionOrNull()?.message}", "download"))
|
||||
}
|
||||
Log.i(TAG, "syncFromRemote complete")
|
||||
} else if (needsDownload) {
|
||||
Log.i(TAG, "syncFromRemote skipped: local repo already populated")
|
||||
}
|
||||
|
||||
val result = action()
|
||||
|
||||
if (needsUpload && result.isSuccess) {
|
||||
Log.i(TAG, "syncToRemote start: $tempRepoDir -> $repoPath")
|
||||
val uploadResult = RemoteTransport.syncToRemote(t, localDir, repoPath, emitProgress, emitByteProgress)
|
||||
if (uploadResult.isFailure) {
|
||||
shouldCleanup = false // PRESERVE local repo — snapshot would be lost
|
||||
Log.e(TAG, "syncToRemote FAILED: ${uploadResult.exceptionOrNull()?.message} — local repo preserved for retry")
|
||||
return@coroutineScope err(AppError.Remote("syncToRemote failed: ${uploadResult.exceptionOrNull()?.message}", "upload"))
|
||||
}
|
||||
Log.i(TAG, "syncToRemote complete")
|
||||
shouldCleanup = true
|
||||
} else if (result.isFailure) {
|
||||
shouldCleanup = true
|
||||
}
|
||||
|
||||
result
|
||||
} catch (e: CancellationException) {
|
||||
shouldCleanup = true
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
shouldCleanup = true
|
||||
err(AppError.Remote(e.message ?: "Unknown error", "sync", cause = e))
|
||||
} finally {
|
||||
progressChannel.close()
|
||||
progressJob.join()
|
||||
if (shouldCleanup) {
|
||||
Log.i(TAG, "withRemoteSync: cleaning up temp dirs")
|
||||
cleanupTempDirs()
|
||||
} else {
|
||||
Log.d(TAG, "withRemoteSync: keeping local repo for subsequent ops")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public safety-net cleanup called by fragment lifecycle.
|
||||
* Waits for any in-progress operation to finish, then deletes temp dirs.
|
||||
*/
|
||||
suspend fun cleanup() {
|
||||
repoSyncMutex.withLock { cleanupTempDirs() }
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.io.File
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
|
||||
@@ -50,54 +44,6 @@ interface RemoteTransport {
|
||||
suspend fun exists(remotePath: String): AppResult<Boolean>
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RemoteTransport"
|
||||
private const val MAX_RETRIES = 3
|
||||
|
||||
/**
|
||||
* Returns true if the exception indicates a transient error worth retrying
|
||||
* (network blip, DNS hiccup, server 5xx), false for permanent errors (4xx).
|
||||
*/
|
||||
private fun isTransientError(e: Exception): Boolean {
|
||||
val msg = (e.message ?: "") + (e.cause?.message ?: "")
|
||||
// DNS / network-layer failures
|
||||
if (msg.contains("Unable to resolve host", ignoreCase = true)) return true
|
||||
if (msg.contains("No address associated", ignoreCase = true)) return true
|
||||
if (msg.contains("ConnectException", ignoreCase = true)) return true
|
||||
if (msg.contains("SocketTimeoutException", ignoreCase = true)) return true
|
||||
if (msg.contains("timeout", ignoreCase = true)) return true
|
||||
if (msg.contains("Connection refused", ignoreCase = true)) return true
|
||||
if (msg.contains("Network is unreachable", ignoreCase = true)) return true
|
||||
// 5xx server errors (502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout)
|
||||
if (Regex("\\b5\\d{2}\\b").containsMatchIn(msg)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute [block] with retries on transient failures.
|
||||
* Uses exponential backoff: 1s, 2s, 4s.
|
||||
*/
|
||||
private suspend fun <T> withRetry(
|
||||
tag: String,
|
||||
block: suspend () -> AppResult<T>
|
||||
): AppResult<T> {
|
||||
var lastError: AppResult<T>? = null
|
||||
for (attempt in 0..MAX_RETRIES) {
|
||||
if (attempt > 0) {
|
||||
val waitMs = 1000L * (1 shl (attempt - 1)) // 1s, 2s, 4s
|
||||
Log.w(TAG, "$tag retry $attempt/$MAX_RETRIES after ${waitMs}ms")
|
||||
delay(waitMs)
|
||||
}
|
||||
val result = block()
|
||||
if (result.isSuccess) return result
|
||||
val err = result.exceptionOrNull()
|
||||
if (err != null && err is Exception && isTransientError(err)) {
|
||||
lastError = result
|
||||
continue
|
||||
}
|
||||
return result // permanent error — don't retry
|
||||
}
|
||||
return lastError ?: err(AppError.Remote("$tag: max retries exceeded", "retry"))
|
||||
}
|
||||
|
||||
fun create(
|
||||
backend: String,
|
||||
@@ -119,252 +65,9 @@ interface RemoteTransport {
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download all files from remote [remoteDir] into [localDir] recursively,
|
||||
* skipping files that already exist locally with the same size.
|
||||
* Deletes local files no longer present on the remote.
|
||||
* Returns failure if any download fails.
|
||||
*/
|
||||
suspend fun syncFromRemote(
|
||||
transport: RemoteTransport,
|
||||
localDir: File,
|
||||
remoteDir: String,
|
||||
onProgress: suspend (TransferProgress) -> Unit = {},
|
||||
onByteProgress: suspend (ByteProgress) -> Unit = {}
|
||||
): AppResult<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
localDir.mkdirs()
|
||||
val remoteFiles = listRemoteRecursive(transport, remoteDir)
|
||||
// Root dir not found (404): treat as empty remote — nothing to download.
|
||||
// This is normal for first-time init where the repo doesn't exist yet.
|
||||
if (remoteFiles == null) {
|
||||
Log.w(TAG, "syncFromRemote: remote dir '$remoteDir' not accessible, treating as empty")
|
||||
return@withContext AppResult.Success(Unit)
|
||||
}
|
||||
onProgress(TransferProgress("list", 0, remoteFiles.size))
|
||||
val remoteByPath = remoteFiles.associateBy { it.path }
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
// Download remote files that are new or have different size
|
||||
var transferred = 0
|
||||
var skipped = 0
|
||||
val syncTotal = remoteFiles.size
|
||||
for ((relPath, info) in remoteByPath) {
|
||||
val localFile = File(localDir, relPath)
|
||||
if (localFile.isFile && localFile.length() == info.size) {
|
||||
Log.d(TAG, "syncFromRemote skip (same size): $relPath")
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
transferred++
|
||||
onProgress(TransferProgress("download", transferred, syncTotal, relPath))
|
||||
localFile.parentFile?.mkdirs()
|
||||
val fullRemotePath = "$remoteDir/$relPath"
|
||||
Log.i(TAG, "syncFromRemote downloading: $fullRemotePath (${info.size} bytes)")
|
||||
val result = withRetry("download($fullRemotePath)") {
|
||||
transport.download(fullRemotePath, localFile.absolutePath, onProgress, onByteProgress)
|
||||
}
|
||||
if (result.isFailure) {
|
||||
errors.add("$fullRemotePath: ${result.exceptionOrNull()?.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// If any download failed, abort before deleting local files —
|
||||
// deleting would destroy valid data for an incomplete sync.
|
||||
if (errors.isNotEmpty()) {
|
||||
return@withContext err(AppError.Remote("syncFromRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}", "sync"))
|
||||
}
|
||||
|
||||
// Delete local files not on remote (e.g. after prune on another client)
|
||||
val localFiles = walkLocalFiles(localDir)
|
||||
val staleLocalPaths = localFiles.keys.filter { it !in remoteByPath }
|
||||
val staleCount = staleLocalPaths.size
|
||||
for ((staleIdx, relPath) in staleLocalPaths.withIndex()) {
|
||||
onProgress(TransferProgress("delete_stale", staleIdx + 1, staleCount))
|
||||
val localFile = localFiles[relPath] ?: continue
|
||||
Log.i(TAG, "syncFromRemote deleting stale local: $relPath")
|
||||
try { localFile.delete() } catch (_: Exception) {}
|
||||
}
|
||||
onProgress(TransferProgress("complete", transferred, syncTotal, "已传输: $transferred 跳过: $skipped"))
|
||||
AppResult.Success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
err(AppError.Remote("syncFromRemote failed: ${e.message}", "sync", cause = e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload all files from [localDir] into [remoteDir] recursively,
|
||||
* skipping files that already exist remotely with the same size.
|
||||
* Deletes remote files that no longer exist locally.
|
||||
* Returns failure if any upload fails.
|
||||
*/
|
||||
suspend fun syncToRemote(
|
||||
transport: RemoteTransport,
|
||||
localDir: File,
|
||||
remoteDir: String,
|
||||
onProgress: suspend (TransferProgress) -> Unit = {},
|
||||
onByteProgress: suspend (ByteProgress) -> Unit = {}
|
||||
): AppResult<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val localFiles = walkLocalFiles(localDir)
|
||||
onProgress(TransferProgress("list", 0, localFiles.size))
|
||||
val remoteResult = listRemoteRecursive(transport, remoteDir)
|
||||
// If the remote dir is not accessible (404 or network error), treat as empty.
|
||||
// Any real upload errors will surface during the actual file uploads below.
|
||||
if (remoteResult == null) {
|
||||
Log.w(TAG, "syncToRemote: remote dir '$remoteDir' not accessible, treating as empty")
|
||||
}
|
||||
val remoteByPath = (remoteResult ?: emptyList()).associateBy { it.path }
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
// Collect unique parent directories that need to exist on remote
|
||||
val remoteDirs = mutableSetOf<String>()
|
||||
for (relPath in localFiles.keys) {
|
||||
val parent = relPath.substringBeforeLast("/", "")
|
||||
if (parent.isNotEmpty()) remoteDirs.add(parent)
|
||||
}
|
||||
|
||||
// Ensure all remote directories exist
|
||||
for (dir in remoteDirs) {
|
||||
transport.mkdirs("$remoteDir/$dir")
|
||||
}
|
||||
|
||||
// Upload new or changed local files
|
||||
var uploaded = 0
|
||||
var uploadSkipped = 0
|
||||
val syncTotal = localFiles.size
|
||||
for ((relPath, localFile) in localFiles) {
|
||||
val remoteInfo = remoteByPath[relPath]
|
||||
if (remoteInfo != null && remoteInfo.size == localFile.length()) {
|
||||
Log.d(TAG, "syncToRemote skip (same size): $relPath")
|
||||
uploadSkipped++
|
||||
continue
|
||||
}
|
||||
uploaded++
|
||||
onProgress(TransferProgress("upload", uploaded, syncTotal, relPath))
|
||||
val fullRemotePath = "$remoteDir/$relPath"
|
||||
Log.i(TAG, "syncToRemote uploading: $fullRemotePath (${localFile.length()} bytes)")
|
||||
val result = withRetry("upload($fullRemotePath)") {
|
||||
transport.upload(localFile.absolutePath, fullRemotePath, onProgress, onByteProgress)
|
||||
}
|
||||
if (result.isFailure) {
|
||||
errors.add("$fullRemotePath: ${result.exceptionOrNull()?.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// If any upload failed, abort before deleting remote files —
|
||||
// deleting during failed sync could lose the only copy on remote.
|
||||
if (errors.isNotEmpty()) {
|
||||
return@withContext err(AppError.Remote("syncToRemote: ${errors.size} file(s) failed: ${errors.joinToString("; ")}", "sync"))
|
||||
}
|
||||
|
||||
// Delete remote files no longer present locally
|
||||
val staleRemotePaths = remoteByPath.keys.filter { it !in localFiles }
|
||||
val staleCount = staleRemotePaths.size
|
||||
for ((staleIdx, relPath) in staleRemotePaths.withIndex()) {
|
||||
onProgress(TransferProgress("delete_stale", staleIdx + 1, staleCount))
|
||||
Log.i(TAG, "syncToRemote deleting stale: $relPath")
|
||||
transport.delete("$remoteDir/$relPath")
|
||||
}
|
||||
onProgress(TransferProgress("complete", uploaded, syncTotal, "已传输: $uploaded 跳过: $uploadSkipped"))
|
||||
AppResult.Success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
err(AppError.Remote("syncToRemote failed: ${e.message}", "sync", cause = e))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private data class FlatFileInfo(val path: String, val size: Long)
|
||||
|
||||
/** Recursively list all files on the remote. Returns null on failure.
|
||||
* Depth-limited to avoid redundant requests on servers that report
|
||||
* files as directories or return self-referencing PROPFIND entries. */
|
||||
private const val MAX_RECURSE_DEPTH = 3
|
||||
|
||||
private suspend fun listRemoteRecursive(
|
||||
transport: RemoteTransport,
|
||||
remoteDir: String
|
||||
): List<FlatFileInfo>? {
|
||||
val result = mutableListOf<FlatFileInfo>()
|
||||
// Pair of (relativePath, depth)
|
||||
val dirsToVisit = mutableListOf("" to 0)
|
||||
|
||||
while (dirsToVisit.isNotEmpty()) {
|
||||
val (subDir, depth) = dirsToVisit.removeLast()
|
||||
if (depth >= MAX_RECURSE_DEPTH) {
|
||||
Log.w(TAG, "listRemoteRecursive: max depth $MAX_RECURSE_DEPTH reached at $remoteDir/$subDir")
|
||||
continue
|
||||
}
|
||||
val fullDir = if (subDir.isEmpty()) remoteDir else "$remoteDir/$subDir"
|
||||
val listResult = withRetry("listFiles($fullDir)") {
|
||||
transport.listFiles(fullDir)
|
||||
}
|
||||
if (listResult.isFailure) {
|
||||
val err = listResult.errorOrNull()
|
||||
// 404 on a subdirectory: directory doesn't exist, skip it silently.
|
||||
// 404 on the root directory: fatal — the remote repo path may be wrong.
|
||||
if (err?.isFileNotFound() == true) {
|
||||
if (subDir.isEmpty()) {
|
||||
Log.e(TAG, "listRemoteRecursive: root dir '$fullDir' returned 404 — repo may not exist or is rate-limited")
|
||||
return null
|
||||
}
|
||||
Log.d(TAG, "listRemoteRecursive: $fullDir -> 404, skipping")
|
||||
continue
|
||||
}
|
||||
Log.e(TAG, "listRemoteRecursive: listFiles FAILED for '$fullDir': ${err?.message}")
|
||||
return null
|
||||
}
|
||||
val entries = listResult.getOrThrow()
|
||||
val parentName = subDir.substringAfterLast("/", subDir)
|
||||
|
||||
for (entry in entries) {
|
||||
val relPath = if (subDir.isEmpty()) entry.name else "$subDir/${entry.name}"
|
||||
if (entry.isDirectory) {
|
||||
// Skip self-referencing entries where the server returns
|
||||
// the directory itself as a child (e.g. data/f9/ contains "f9")
|
||||
if (entry.name == parentName) {
|
||||
Log.d(TAG, "listRemoteRecursive skip self-ref: $relPath")
|
||||
continue
|
||||
}
|
||||
dirsToVisit.add(relPath to depth + 1)
|
||||
} else {
|
||||
result.add(FlatFileInfo(relPath, entry.size))
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "listRemoteRecursive: $remoteDir → ${result.size} files in ${result.map { it.path }.toSet().size} paths")
|
||||
return result
|
||||
}
|
||||
|
||||
/** Walk the local directory tree, returning relative-path → File mapping for all files. */
|
||||
private fun walkLocalFiles(localDir: File): Map<String, File> {
|
||||
val result = mutableMapOf<String, File>()
|
||||
val dirsToVisit = mutableListOf(localDir)
|
||||
val basePath = localDir.absolutePath
|
||||
|
||||
while (dirsToVisit.isNotEmpty()) {
|
||||
val dir = dirsToVisit.removeLast()
|
||||
for (file in dir.listFiles() ?: emptyArray()) {
|
||||
if (file.isDirectory) {
|
||||
dirsToVisit.add(file)
|
||||
} else {
|
||||
val relPath = file.absolutePath.removePrefix("$basePath/")
|
||||
result[relPath] = file
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Extension to check if an [AppError] represents a "not found" remote error. */
|
||||
private fun AppError.isFileNotFound(): Boolean =
|
||||
internal fun AppError.isFileNotFound(): Boolean =
|
||||
this is AppError.Remote && this.isNotFound
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Manages [ResticRestBridge] lifecycle: create, start, stop, clean cache.
|
||||
*
|
||||
* Usage:
|
||||
* ```kotlin
|
||||
* bridgeRunner.withBridge(backend, url, user, pass, share, domain, repoPath) { bridgeUrl ->
|
||||
* // RESTIC_REPOSITORY = bridgeUrl
|
||||
* restic commands go here
|
||||
* }
|
||||
* // bridge stopped + cache cleaned automatically
|
||||
* ```
|
||||
*/
|
||||
class RestBridgeRunner {
|
||||
|
||||
private val TAG = "RestBridgeRunner"
|
||||
|
||||
/**
|
||||
* Start a REST bridge for the given [backend], execute [block] with the
|
||||
* bridge URL, then stop and clean up.
|
||||
*
|
||||
* For [backend] == "local", the bridge is not started and [block] receives
|
||||
* `null`.
|
||||
*/
|
||||
suspend fun <T> withBridge(
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendUser: String,
|
||||
backendPass: String,
|
||||
backendShare: String,
|
||||
backendDomain: String,
|
||||
repoPath: String,
|
||||
cacheDir: File,
|
||||
transportFactory: (
|
||||
backend: String,
|
||||
url: String,
|
||||
user: String,
|
||||
pass: String,
|
||||
share: String,
|
||||
domain: String
|
||||
) -> RemoteTransport? = ::createTransport,
|
||||
block: suspend (bridgeUrl: String) -> T
|
||||
): T {
|
||||
if (backend == "local") {
|
||||
return block(repoPath)
|
||||
}
|
||||
|
||||
val transport = transportFactory(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain)
|
||||
?: return block(repoPath)
|
||||
|
||||
val remoteBase = buildRemoteBase(backend, backendUrl, backendShare, repoPath)
|
||||
val bridge = ResticRestBridge(transport, remoteBase, cacheDir)
|
||||
|
||||
try {
|
||||
bridge.start()
|
||||
val port = bridge.listeningPort
|
||||
if (port < 0) {
|
||||
throw IllegalStateException("REST bridge failed to bind a port")
|
||||
}
|
||||
val bridgeUrl = "rest:http://127.0.0.1:$port/$repoPath"
|
||||
Log.i(TAG, "REST bridge started on port $port for $remoteBase")
|
||||
return block(bridgeUrl)
|
||||
} finally {
|
||||
try {
|
||||
bridge.stop()
|
||||
} catch (_: Exception) {}
|
||||
Log.d(TAG, "REST bridge stopped")
|
||||
// Clean up any leftover blob temp files
|
||||
val blobs = cacheDir.listFiles { f -> f.name.startsWith("restic_blob_") }
|
||||
if (blobs != null) {
|
||||
for (f in blobs) f.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the remote base path for the REST bridge. */
|
||||
private fun buildRemoteBase(
|
||||
backend: String,
|
||||
backendUrl: String,
|
||||
backendShare: String,
|
||||
repoPath: String
|
||||
): String {
|
||||
return when (backend) {
|
||||
"smb" -> "smb://${backendUrl.trimEnd('/')}/$backendShare/$repoPath"
|
||||
"webdav" -> "${backendUrl.trimEnd('/')}/${repoPath.trimStart('/')}"
|
||||
else -> repoPath
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Default transport factory: delegates to [RemoteTransport.create]. */
|
||||
fun createTransport(
|
||||
backend: String,
|
||||
url: String,
|
||||
user: String,
|
||||
pass: String,
|
||||
share: String,
|
||||
domain: String
|
||||
): RemoteTransport? {
|
||||
return RemoteTransport.create(backend, url, user, pass, share, domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,20 +8,23 @@ import kotlin.coroutines.coroutineContext
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
import java.io.File
|
||||
|
||||
|
||||
/**
|
||||
* Backup operations: running restic backup and parsing its summary output.
|
||||
*
|
||||
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||
* [RemoteSyncManager] which are shared across sub-modules.
|
||||
* [RestBridgeRunner] which are shared across sub-modules.
|
||||
*/
|
||||
class ResticBackup(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val syncManager: RemoteSyncManager
|
||||
private val bridgeRunner: RestBridgeRunner
|
||||
) {
|
||||
private val TAG = "ResticBackup"
|
||||
var cacheDir: String = ""
|
||||
var backendDomain: String = ""
|
||||
|
||||
// ── Backup ─────────────────────────────────────────
|
||||
|
||||
@@ -36,35 +39,103 @@ class ResticBackup(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
|
||||
): AppResult<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = true,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
|
||||
if (backend == "local") {
|
||||
val args = mutableListOf("backup", "--json")
|
||||
for (path in paths) args.add(path)
|
||||
for (tag in tags) { args.add("--tag"); args.add(tag) }
|
||||
if (hostname != null) { args.add("--host"); args.add(hostname) }
|
||||
|
||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runResticStreaming(env, args) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (_: Exception) { /* ignore non-JSON lines */ }
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
return@withRemoteSync err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) return@withContext err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
|
||||
parseBackupSummary(result.stdout)
|
||||
} else {
|
||||
bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl ->
|
||||
val args = mutableListOf("backup", "--json")
|
||||
for (path in paths) args.add(path)
|
||||
for (tag in tags) { args.add("--tag"); args.add(tag) }
|
||||
if (hostname != null) { args.add("--host"); args.add(hostname) }
|
||||
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runResticStreaming(env, args) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) return@withBridge err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
|
||||
parseBackupSummary(result.stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Streaming backup (stdin) ──────────────────────
|
||||
|
||||
/**
|
||||
* Run restic backup in --stdin mode, reading tar data from [stdinFile] (FIFO).
|
||||
* [extraPaths] are files/directories backed up alongside the streaming data
|
||||
* (e.g. APK paths, metadata directory).
|
||||
*/
|
||||
suspend fun backupStdin(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
stdinFile: File,
|
||||
extraPaths: List<String>,
|
||||
tags: List<String> = emptyList(),
|
||||
hostname: String? = null,
|
||||
backend: String = "local",
|
||||
backendUrl: String = "",
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {}
|
||||
): AppResult<ResticWrapper.BackupSummary> = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
|
||||
|
||||
val args = mutableListOf("backup", "--json", "--stdin", "--stdin-filename", "app_data.tar")
|
||||
for (path in extraPaths) args.add(path)
|
||||
for (tag in tags) { args.add("--tag"); args.add(tag) }
|
||||
if (hostname != null) { args.add("--host"); args.add(hostname) }
|
||||
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runResticWithStdin(env, args, stdinFile) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticWithStdin
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) return@withContext err(AppError.Restic("restic stream backup 失败", result.exitCode, result.stderr))
|
||||
parseBackupSummary(result.stdout)
|
||||
} else {
|
||||
bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runResticWithStdin(env, args, stdinFile) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticWithStdin
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
if (progress.messageType == "status") emit(progress)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) return@withBridge err(AppError.Restic("restic stream backup 失败", result.exitCode, result.stderr))
|
||||
parseBackupSummary(result.stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,13 +33,6 @@ object ResticBinary {
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the temp directory used as local restic repo for remote backends. */
|
||||
fun getTempRepoDir(context: Context): String {
|
||||
val dir = File(context.cacheDir, "restic_remote_repo")
|
||||
dir.mkdirs()
|
||||
Log.d(TAG, "tempRepoDir = ${dir.absolutePath}")
|
||||
return dir.absolutePath
|
||||
}
|
||||
|
||||
fun isReady(): Boolean = cachedBinaryPath != null
|
||||
}
|
||||
|
||||
@@ -158,6 +158,90 @@ class ResticCommandRunner {
|
||||
CommandResult("", e.message ?: "Unknown error", -1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run restic with stdin redirected from [stdinFile] (FIFO or regular file).
|
||||
* Calls [onLine] for each stdout line (for streaming progress).
|
||||
*/
|
||||
suspend fun runResticWithStdin(
|
||||
env: Map<String, String>,
|
||||
args: List<String>,
|
||||
stdinFile: File,
|
||||
onLine: suspend (String) -> Unit
|
||||
): CommandResult = withContext(Dispatchers.IO) {
|
||||
val cmdArgs = buildCommandArgs(args)
|
||||
Log.i(TAG, "runResticWithStdin cmd=${cmdArgs.joinToString(" ")} stdin=${stdinFile.absolutePath}")
|
||||
Log.d(TAG, "runResticWithStdin REPOSITORY=${env["RESTIC_REPOSITORY"]}")
|
||||
env["TMPDIR"]?.let { File(it).mkdirs() }
|
||||
|
||||
var process: Process? = null
|
||||
try {
|
||||
|
||||
val pb = ProcessBuilder(cmdArgs)
|
||||
pb.environment().putAll(env)
|
||||
pb.redirectErrorStream(false)
|
||||
process = pb.start()
|
||||
|
||||
// Pipe stdin from file to process on a daemon thread (API 24 compat)
|
||||
Thread {
|
||||
try {
|
||||
val fis = java.io.FileInputStream(stdinFile)
|
||||
val pos = process!!.outputStream
|
||||
fis.use { input -> pos.use { output -> input.copyTo(output) } }
|
||||
} catch (_: Exception) {
|
||||
// FIFO writer closed; stdin pipe ends naturally
|
||||
}
|
||||
}.apply { isDaemon = true; start() }
|
||||
val stdoutText = StringBuilder()
|
||||
val reader = process.inputStream.bufferedReader()
|
||||
|
||||
try {
|
||||
var line: String
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
if (!coroutineContext.isActive) {
|
||||
process.destroy()
|
||||
break
|
||||
}
|
||||
stdoutText.appendLine(line)
|
||||
onLine(line)
|
||||
}
|
||||
} finally {
|
||||
try { reader.close() } catch (_: Exception) {}
|
||||
}
|
||||
val stderrBytes = try { process.errorStream.use { it.readAllBytesCompat() } } catch (_: Exception) { byteArrayOf() }
|
||||
val stderrText = stderrBytes.decodeToString().trim()
|
||||
val exitCode = try {
|
||||
val deadline = System.currentTimeMillis() + 60_000
|
||||
var exited = false
|
||||
while (System.currentTimeMillis() < deadline && !exited) {
|
||||
try {
|
||||
process.exitValue()
|
||||
exited = true
|
||||
} catch (_: IllegalThreadStateException) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
}
|
||||
if (exited) {
|
||||
process.exitValue()
|
||||
} else {
|
||||
Log.w(TAG, "runResticWithStdin: process did not exit within 60s, destroying")
|
||||
process.destroy()
|
||||
process.waitFor()
|
||||
process.exitValue()
|
||||
}
|
||||
} catch (_: Exception) { -1 }
|
||||
|
||||
Log.i(TAG, "runResticWithStdin exitCode=$exitCode stdout_len=${stdoutText.length}")
|
||||
if (stderrText.isNotEmpty()) Log.w(TAG, "runResticWithStdin stderr: ${stderrText}")
|
||||
CommandResult(stdoutText.toString().trim(), stderrText.trim(), exitCode)
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "runResticWithStdin exception", e)
|
||||
try { process?.destroy() } catch (_: Exception) {}
|
||||
CommandResult("", e.message ?: "Unknown error", -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,32 +5,38 @@ package com.example.androidbackupgui.backup
|
||||
*/
|
||||
class ResticEnvResolver {
|
||||
|
||||
/** Build environment for restic. For SMB/WebDAV backends, uses local temp dir as repo. */
|
||||
fun buildFullEnv(
|
||||
repoPath: String,
|
||||
|
||||
/** Build environment for non-local backends using the REST bridge URL. */
|
||||
fun buildBridgeEnv(
|
||||
password: String,
|
||||
backend: String = "local",
|
||||
backendUrl: String = "",
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
tempRepoDir: String = ""
|
||||
bridgeUrl: String,
|
||||
cacheDir: String
|
||||
): Map<String, String> {
|
||||
val env = HashMap(System.getenv() ?: emptyMap())
|
||||
env["RESTIC_REPOSITORY"] = if (backend == "smb" || backend == "webdav") {
|
||||
tempRepoDir
|
||||
} else {
|
||||
buildRepoUrl(backend, repoPath, backendUrl)
|
||||
}
|
||||
env["RESTIC_REPOSITORY"] = bridgeUrl
|
||||
env["RESTIC_PASSWORD"] = password
|
||||
// Restic needs HOME for its cache on Android (no $HOME by default).
|
||||
// Both local and remote backends use the same cache dir (sibling of tempRepoDir).
|
||||
if (tempRepoDir.isNotEmpty()) {
|
||||
val cacheDir = tempRepoDir.substringBeforeLast("/") + "/restic_cache"
|
||||
if (cacheDir.isNotEmpty()) {
|
||||
env["HOME"] = cacheDir
|
||||
env["XDG_CACHE_HOME"] = cacheDir
|
||||
// Restic needs a writable temp dir for pack files. Android has no /tmp.
|
||||
val tmpDir = tempRepoDir.substringBeforeLast("/") + "/restic_tmp"
|
||||
val tmpDir = "$cacheDir/restic_tmp"
|
||||
env["TMPDIR"] = tmpDir
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/** Build environment for local repository. */
|
||||
fun buildLocalEnv(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
cacheDir: String
|
||||
): Map<String, String> {
|
||||
val env = HashMap(System.getenv() ?: emptyMap())
|
||||
env["RESTIC_REPOSITORY"] = repoPath
|
||||
env["RESTIC_PASSWORD"] = password
|
||||
if (cacheDir.isNotEmpty()) {
|
||||
env["HOME"] = cacheDir
|
||||
env["XDG_CACHE_HOME"] = cacheDir
|
||||
val tmpDir = "$cacheDir/restic_tmp"
|
||||
env["TMPDIR"] = tmpDir
|
||||
}
|
||||
return env
|
||||
|
||||
@@ -5,20 +5,32 @@ import kotlinx.coroutines.withContext
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Repository maintenance operations: prune, check, stats.
|
||||
*
|
||||
* [prune] requires both download and upload (it removes pack files from the remote).
|
||||
* [check] and [stats] are download-only read operations.
|
||||
*
|
||||
* For remote backends, uses [RestBridgeRunner] to serve the backend via REST,
|
||||
* so restic always sees a local rest-server repository. For local backends,
|
||||
* operates directly on the repo path.
|
||||
*
|
||||
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||
* [RemoteSyncManager] which are shared across sub-modules.
|
||||
* [RestBridgeRunner] which are shared across sub-modules.
|
||||
*/
|
||||
class ResticMaintenance(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val syncManager: RemoteSyncManager
|
||||
private val bridgeRunner: RestBridgeRunner
|
||||
) {
|
||||
/** Cache directory for restic env and bridge temp files. Set by [ResticWrapper]. */
|
||||
var cacheDir: String = ""
|
||||
|
||||
/** SMB NTLM domain for remote backend. Set by [ResticWrapper]. */
|
||||
var backendDomain: String = ""
|
||||
|
||||
// ── Prune ──────────────────────────────────────────
|
||||
|
||||
suspend fun prune(
|
||||
@@ -29,19 +41,23 @@ class ResticMaintenance(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): AppResult<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = true,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, "prune")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runRestic(env, "prune")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,19 +71,23 @@ class ResticMaintenance(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): AppResult<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = false,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, "check")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic check 失败", result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runRestic(env, "check")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic check 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,19 +101,23 @@ class ResticMaintenance(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): AppResult<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = false,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, "stats")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic stats 失败", result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runRestic(env, "stats")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic stats 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,20 +6,30 @@ import kotlinx.coroutines.withContext
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Repository lifecycle operations: init and repo URL construction.
|
||||
*
|
||||
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||
* [RemoteSyncManager] which are shared across sub-modules.
|
||||
* [RestBridgeRunner] which are shared across sub-modules.
|
||||
*
|
||||
* For "local" backends, invokes restic directly against [repoPath].
|
||||
* For remote backends (SMB/WebDAV/rest-server), starts a temporary REST bridge
|
||||
* via [RestBridgeRunner.withBridge] and points restic at the bridge URL.
|
||||
*/
|
||||
class ResticRepoInit(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val syncManager: RemoteSyncManager
|
||||
private val bridgeRunner: RestBridgeRunner
|
||||
) {
|
||||
private val TAG = "ResticWrapper"
|
||||
|
||||
/** Cache directory for restic env and bridge temp files. Set by ResticWrapper. */
|
||||
var cacheDir: String = ""
|
||||
/** NTLM domain for SMB authentication. Set by ResticWrapper. */
|
||||
var backendDomain: String = ""
|
||||
|
||||
// ── Repository initialization ──────────────────────
|
||||
|
||||
suspend fun init(
|
||||
@@ -30,38 +40,45 @@ class ResticRepoInit(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): AppResult<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = true,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
val result = runner.runRestic(env, "init")
|
||||
// exitCode 0 = brand new repo created, needs upload
|
||||
if (result.exitCode == 0) {
|
||||
return@withRemoteSync AppResult.Success(Unit)
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
runInit(env)
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
runInit(env)
|
||||
}
|
||||
// exitCode 1 = config already exists; verify the repo is actually usable
|
||||
if (result.exitCode == 1) {
|
||||
val 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@withRemoteSync AppResult.Success(Unit)
|
||||
}
|
||||
// Config exists but repo is corrupted (wrong password, missing keys, etc.)
|
||||
return@withRemoteSync err(
|
||||
AppError.Restic("仓库已存在但无法验证", verify.exitCode, verify.stderr)
|
||||
)
|
||||
}
|
||||
err(AppError.Restic("restic init 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared init logic: run restic init, verify on exitCode 1. */
|
||||
private suspend fun runInit(env: Map<String, String>): AppResult<Unit> {
|
||||
val result = runner.runRestic(env, "init")
|
||||
// exitCode 0 = brand new repo created
|
||||
if (result.exitCode == 0) {
|
||||
return AppResult.Success(Unit)
|
||||
}
|
||||
// exitCode 1 = config already exists; verify the repo is actually usable
|
||||
if (result.exitCode == 1) {
|
||||
val 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.)
|
||||
return err(
|
||||
AppError.Restic("仓库已存在但无法验证", verify.exitCode, verify.stderr)
|
||||
)
|
||||
}
|
||||
return err(AppError.Restic("restic init 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
|
||||
// ── Public URL helper ──────────────────────────────
|
||||
|
||||
/** Build a display-friendly repository URL for UI. */
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import fi.iki.elonen.NanoHTTPD
|
||||
import fi.iki.elonen.NanoHTTPD.IHTTPSession
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* NanoHTTPD-based REST bridge implementing the restic REST backend API.
|
||||
*
|
||||
* Translates restic HTTP requests into [RemoteTransport] calls so that restic
|
||||
* can read/write blobs directly to SMB/WebDAV without a local staging repo.
|
||||
*
|
||||
* Port is auto-assigned (0); use [listeningPort] after start().
|
||||
*/
|
||||
class ResticRestBridge(
|
||||
private val transport: RemoteTransport,
|
||||
private val remoteBase: String,
|
||||
private val cacheDir: File
|
||||
) : NanoHTTPD(0) {
|
||||
|
||||
private val TAG = "ResticRestBridge"
|
||||
|
||||
init {
|
||||
cacheDir.mkdirs()
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun serve(session: IHTTPSession): Response {
|
||||
val uri = session.uri
|
||||
val method = session.method
|
||||
val headers = session.headers
|
||||
val params = session.parms
|
||||
|
||||
Log.d(TAG, "$method $uri")
|
||||
|
||||
return try {
|
||||
handleRequest(method, uri, headers, params, session)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "request failed: $method $uri", e)
|
||||
newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR,
|
||||
"text/plain",
|
||||
e.message ?: "Internal error"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRequest(
|
||||
method: NanoHTTPD.Method,
|
||||
uri: String,
|
||||
headers: Map<String, String>,
|
||||
params: Map<String, String>,
|
||||
session: IHTTPSession
|
||||
): Response {
|
||||
val path = uri.trimEnd('/')
|
||||
|
||||
// POST {path}?create=true -> mkdirs
|
||||
if (method == NanoHTTPD.Method.POST && params["create"] == "true") {
|
||||
return runBlocking {
|
||||
when (transport.mkdirs(remoteBase)) {
|
||||
is AppResult.Success -> newFixedLengthResponse(
|
||||
Response.Status.OK, "text/plain", ""
|
||||
)
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain", "mkdirs failed"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val segments = path.split("/").filter { it.isNotEmpty() }
|
||||
|
||||
if (segments.isEmpty()) {
|
||||
return newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Invalid path")
|
||||
}
|
||||
|
||||
val firstSegment = segments.first()
|
||||
|
||||
// /config endpoints
|
||||
if (firstSegment == "config" && segments.size == 1) {
|
||||
return handleConfig(method, headers, session)
|
||||
}
|
||||
|
||||
// /{type}/ or /{type}/{name}
|
||||
val type = firstSegment
|
||||
val name = if (segments.size >= 2) segments.drop(1).joinToString("/") else null
|
||||
|
||||
if (name == null) {
|
||||
if (method == NanoHTTPD.Method.GET) {
|
||||
return handleListBlobs(type)
|
||||
}
|
||||
return newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "text/plain", "")
|
||||
}
|
||||
|
||||
return when (method) {
|
||||
NanoHTTPD.Method.HEAD -> handleHeadBlob(type, name)
|
||||
NanoHTTPD.Method.GET -> handleGetBlob(type, name, headers)
|
||||
NanoHTTPD.Method.POST -> handlePostBlob(type, name, session)
|
||||
NanoHTTPD.Method.DELETE -> handleDeleteBlob(type, name)
|
||||
else -> newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "text/plain", "")
|
||||
}
|
||||
}
|
||||
|
||||
// -- Config endpoints -------------------------------------------
|
||||
/**
|
||||
* 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? {
|
||||
return try {
|
||||
val tmpFile = File(tmpDir, "restic_blob_${UUID.randomUUID()}")
|
||||
val input = (session as NanoHTTPD.HTTPSession).inputStream
|
||||
tmpFile.outputStream().use { output -> input.copyTo(output) }
|
||||
tmpFile
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
private fun handleConfig(
|
||||
method: NanoHTTPD.Method,
|
||||
headers: Map<String, String>,
|
||||
session: IHTTPSession
|
||||
): Response = runBlocking {
|
||||
val remotePath = "$remoteBase/config"
|
||||
when (method) {
|
||||
NanoHTTPD.Method.HEAD -> {
|
||||
when (val result = transport.exists(remotePath)) {
|
||||
is AppResult.Success -> {
|
||||
if (result.data) {
|
||||
newFixedLengthResponse(Response.Status.OK, "application/octet-stream", "")
|
||||
} else {
|
||||
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "")
|
||||
}
|
||||
}
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND, "text/plain", ""
|
||||
)
|
||||
}
|
||||
}
|
||||
NanoHTTPD.Method.GET -> {
|
||||
val tempFile = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
|
||||
try {
|
||||
when (transport.download(remotePath, tempFile.absolutePath)) {
|
||||
is AppResult.Success -> {
|
||||
val bytes = tempFile.readBytes()
|
||||
newChunkedResponse(Response.Status.OK, "application/octet-stream", bytes.inputStream())
|
||||
}
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND, "text/plain", ""
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
NanoHTTPD.Method.POST -> {
|
||||
val tmpFile = streamBodyToFile(session, cacheDir)
|
||||
if (tmpFile == null) return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain", "body read failed"
|
||||
)
|
||||
try {
|
||||
when (transport.upload(tmpFile.absolutePath, remotePath)) {
|
||||
is AppResult.Success -> newFixedLengthResponse(
|
||||
Response.Status.OK, "text/plain", ""
|
||||
)
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain", "upload failed"
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
tmpFile.delete()
|
||||
}
|
||||
}
|
||||
else -> newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "text/plain", "")
|
||||
}
|
||||
}
|
||||
|
||||
// -- Blob listing -----------------------------------------------
|
||||
|
||||
private fun handleListBlobs(type: String): Response = runBlocking {
|
||||
val remoteDir = "$remoteBase/$type"
|
||||
when (val result = transport.listFiles(remoteDir)) {
|
||||
is AppResult.Success -> {
|
||||
val items = result.data
|
||||
val json = buildV2Json(items)
|
||||
newFixedLengthResponse(Response.Status.OK, "application/vnd.x.restic.rest.v2", json)
|
||||
}
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND, "text/plain", ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildV2Json(items: List<RemoteTransport.RemoteFileInfo>): String {
|
||||
val sb = StringBuilder("[")
|
||||
var first = true
|
||||
for (item in items) {
|
||||
if (item.isDirectory) continue
|
||||
if (!first) sb.append(",")
|
||||
first = false
|
||||
sb.append("{\"name\":\"${item.name}\",\"size\":${item.size}}")
|
||||
}
|
||||
sb.append("]")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
// -- Blob HEAD (exists + size) ----------------------------------
|
||||
|
||||
private fun handleHeadBlob(type: String, name: String): Response = runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
when (val result = transport.exists(remotePath)) {
|
||||
is AppResult.Success -> {
|
||||
if (result.data) {
|
||||
newFixedLengthResponse(Response.Status.OK, "application/octet-stream", "")
|
||||
} else {
|
||||
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "")
|
||||
}
|
||||
}
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND, "text/plain", ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Blob GET (download with optional Range) --------------------
|
||||
|
||||
private fun handleGetBlob(
|
||||
type: String,
|
||||
name: String,
|
||||
headers: Map<String, String>
|
||||
): Response = runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
// Use RandomAccessFile to avoid loading entire blob into memory
|
||||
val tempFile = File(cacheDir, "restic_blob_${UUID.randomUUID()}")
|
||||
try {
|
||||
when (transport.download(remotePath, tempFile.absolutePath)) {
|
||||
is AppResult.Success -> {
|
||||
val rangeHeader = headers["range"]?.lowercase()
|
||||
|
||||
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
|
||||
// Range request — only works with known file size
|
||||
val fileLen = tempFile.length()
|
||||
val range = rangeHeader.removePrefix("bytes=").trim()
|
||||
val dashIdx = range.indexOf('-')
|
||||
val start = range.substring(0, if (dashIdx >= 0) dashIdx else range.length)
|
||||
.toLongOrNull() ?: 0L
|
||||
val end = if (dashIdx >= 0 && dashIdx + 1 < range.length) {
|
||||
range.substring(dashIdx + 1).toLongOrNull() ?: (fileLen - 1)
|
||||
} else {
|
||||
fileLen - 1
|
||||
}
|
||||
|
||||
val actualEnd = minOf(end, fileLen - 1).coerceAtLeast(0)
|
||||
val actualStart = minOf(start, actualEnd).coerceAtLeast(0)
|
||||
val chunkSize = (actualEnd - actualStart + 1).toInt()
|
||||
val chunk = ByteArray(chunkSize)
|
||||
try {
|
||||
val raf = java.io.RandomAccessFile(tempFile, "r")
|
||||
raf.use { it.seek(actualStart); it.readFully(chunk) }
|
||||
} catch (_: Exception) {
|
||||
return@runBlocking newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain", "range read failed"
|
||||
)
|
||||
}
|
||||
|
||||
val response = newChunkedResponse(
|
||||
Response.Status.PARTIAL_CONTENT,
|
||||
"application/octet-stream",
|
||||
chunk.inputStream()
|
||||
)
|
||||
response.addHeader("Content-Range", "bytes $actualStart-$actualEnd/$fileLen")
|
||||
response.addHeader("Content-Length", chunkSize.toString())
|
||||
return@runBlocking response
|
||||
}
|
||||
|
||||
// Full file — stream directly without loading into memory
|
||||
val response = newChunkedResponse(
|
||||
Response.Status.OK,
|
||||
"application/octet-stream",
|
||||
tempFile.inputStream()
|
||||
)
|
||||
response.addHeader("Content-Length", tempFile.length().toString())
|
||||
response
|
||||
}
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.NOT_FOUND, "text/plain", ""
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
// -- Blob POST (upload) -----------------------------------------
|
||||
|
||||
private fun handlePostBlob(
|
||||
type: String,
|
||||
name: String,
|
||||
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"
|
||||
)
|
||||
try {
|
||||
when (transport.upload(tmpFile.absolutePath, remotePath)) {
|
||||
is AppResult.Success -> newFixedLengthResponse(
|
||||
Response.Status.OK, "text/plain", ""
|
||||
)
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain", "upload failed"
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
tmpFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
// -- Blob DELETE ------------------------------------------------
|
||||
|
||||
private fun handleDeleteBlob(type: String, name: String): Response = runBlocking {
|
||||
val remotePath = "$remoteBase/$type/$name"
|
||||
when (transport.delete(remotePath)) {
|
||||
is AppResult.Success -> newFixedLengthResponse(
|
||||
Response.Status.OK, "text/plain", ""
|
||||
)
|
||||
is AppResult.Failure -> newFixedLengthResponse(
|
||||
Response.Status.INTERNAL_ERROR, "text/plain", "delete failed"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,31 @@ import com.example.androidbackupgui.backup.err
|
||||
*
|
||||
* Both are download-only operations (no upload to remote needed).
|
||||
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||
* [RemoteSyncManager] which are shared across sub-modules.
|
||||
* [RestBridgeRunner] which are shared across sub-modules.
|
||||
*
|
||||
* @property cacheDir Cache directory for restic env and bridge temp files; set by [ResticWrapper].
|
||||
* @property backendDomain Domain for SMB NTLM authentication; set by [ResticWrapper].
|
||||
*/
|
||||
class ResticRestore(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val syncManager: RemoteSyncManager
|
||||
private val bridgeRunner: RestBridgeRunner
|
||||
) {
|
||||
/** Cache directory for restic env and bridge temp files. Set by [ResticWrapper]. */
|
||||
var cacheDir: String = ""
|
||||
|
||||
/** Domain for SMB NTLM authentication. Set by [ResticWrapper]. */
|
||||
var backendDomain: String = ""
|
||||
|
||||
// ── Restore ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Restore a snapshot to [targetPath], optionally filtered by [include] pattern.
|
||||
*
|
||||
* For local backends, builds env via [ResticEnvResolver.buildLocalEnv] and runs
|
||||
* restic restore directly. For remote backends, proxies through [RestBridgeRunner]
|
||||
* using a local REST bridge, building env via [ResticEnvResolver.buildBridgeEnv].
|
||||
*/
|
||||
suspend fun restore(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
@@ -35,22 +51,17 @@ class ResticRestore(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
onProgress: suspend (String) -> Unit = {}
|
||||
): AppResult<Unit> = withContext(Dispatchers.IO) {
|
||||
val emit: suspend (String) -> Unit = { s -> withContext(Dispatchers.Main) { onProgress(s) } }
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = false,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
|
||||
if (backend == "local") {
|
||||
File(targetPath).mkdirs()
|
||||
|
||||
val args = mutableListOf("restore", snapshotId, "--target", targetPath, "--json")
|
||||
if (include != null) { args.add("--include"); args.add(include) }
|
||||
|
||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runResticStreaming(env, args) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
@@ -69,11 +80,48 @@ class ResticRestore(
|
||||
|
||||
if (result.exitCode == 0) AppResult.Success(Unit)
|
||||
else err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare, backendDomain,
|
||||
repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
File(targetPath).mkdirs()
|
||||
|
||||
val args = mutableListOf("restore", snapshotId, "--target", targetPath, "--json")
|
||||
if (include != null) { args.add("--include"); args.add(include) }
|
||||
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runResticStreaming(env, args) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
when (progress.messageType) {
|
||||
"status" -> {
|
||||
val percent = "%.1f".format(progress.percentDone * 100)
|
||||
emit("恢复进度: $percent%")
|
||||
}
|
||||
"summary" -> {
|
||||
emit("恢复完成: ${progress.totalFiles} 个文件")
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { emit(line) }
|
||||
}
|
||||
|
||||
if (result.exitCode == 0) AppResult.Success(Unit)
|
||||
else err(AppError.Restic("restic restore 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── File dump ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dump the contents of a single file from a snapshot.
|
||||
*
|
||||
* For local backends, builds env via [ResticEnvResolver.buildLocalEnv] and runs
|
||||
* restic dump directly. For remote backends, proxies through [RestBridgeRunner]
|
||||
* using a local REST bridge, building env via [ResticEnvResolver.buildBridgeEnv].
|
||||
*/
|
||||
suspend fun dump(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
@@ -83,19 +131,23 @@ class ResticRestore(
|
||||
backendUrl: String = "",
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
backendShare: String = ""
|
||||
): AppResult<String> = withContext(Dispatchers.IO) {
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = false,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
if (backend == "local") {
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, "dump", snapshotId, filePath)
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare, backendDomain,
|
||||
repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runRestic(env, "dump", snapshotId, filePath)
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,21 +5,31 @@ import kotlinx.coroutines.withContext
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.err
|
||||
import java.io.File
|
||||
|
||||
|
||||
/**
|
||||
* Snapshot listing and retention policy operations.
|
||||
*
|
||||
* [listSnapshots] is download-only; [forget] requires both download and upload
|
||||
* (forget removes snapshots from the remote).
|
||||
* [listSnapshots] is download-only; [forget] removes snapshots from the remote.
|
||||
*
|
||||
* For "local" backends, invokes restic directly against [repoPath].
|
||||
* For remote backends (SMB/WebDAV/rest-server), starts a temporary REST bridge
|
||||
* via [RestBridgeRunner.withBridge] and points restic at the bridge URL.
|
||||
*
|
||||
* Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and
|
||||
* [RemoteSyncManager] which are shared across sub-modules.
|
||||
* [RestBridgeRunner] which are shared across sub-modules.
|
||||
*/
|
||||
class ResticSnapshotOps(
|
||||
private val runner: ResticCommandRunner,
|
||||
private val envResolver: ResticEnvResolver,
|
||||
private val syncManager: RemoteSyncManager
|
||||
private val bridgeRunner: RestBridgeRunner
|
||||
) {
|
||||
/** Cache directory for restic env and bridge temp files. Set by ResticWrapper. */
|
||||
var cacheDir: String = ""
|
||||
/** NTLM domain for SMB authentication. Set by ResticWrapper. */
|
||||
var backendDomain: String = ""
|
||||
|
||||
// ── List snapshots ─────────────────────────────────
|
||||
|
||||
suspend fun listSnapshots(
|
||||
@@ -31,22 +41,16 @@ class ResticSnapshotOps(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): AppResult<List<ResticWrapper.ResticSnapshot>> = withContext(Dispatchers.IO) {
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = false,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
if (backend == "local") {
|
||||
val args = mutableListOf("snapshots", "--json")
|
||||
if (tag != null) { args.add("--tag"); args.add(tag) }
|
||||
|
||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, args)
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
return@withRemoteSync err(AppError.Restic("restic snapshots 失败", result.exitCode, result.stderr))
|
||||
return@withContext err(AppError.Restic("restic snapshots 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -54,9 +58,33 @@ class ResticSnapshotOps(
|
||||
result.stdout.ifEmpty { "[]" }
|
||||
)
|
||||
AppResult.Success(snapshots.sortedByDescending { it.time })
|
||||
} catch (e: Exception) {
|
||||
} catch (e: Exception) {
|
||||
err(AppError.Parse("解析快照 JSON 失败", e.message ?: ""))
|
||||
}
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val args = mutableListOf("snapshots", "--json")
|
||||
if (tag != null) { args.add("--tag"); args.add(tag) }
|
||||
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runRestic(env, args)
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
return@withBridge err(AppError.Restic("restic snapshots 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
|
||||
try {
|
||||
val snapshots = resticJson.decodeFromString<List<ResticWrapper.ResticSnapshot>>(
|
||||
result.stdout.ifEmpty { "[]" }
|
||||
)
|
||||
AppResult.Success(snapshots.sortedByDescending { it.time })
|
||||
} catch (e: Exception) {
|
||||
err(AppError.Parse("解析快照 JSON 失败", e.message ?: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,14 +102,8 @@ class ResticSnapshotOps(
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): AppResult<String> = withContext(Dispatchers.IO) {
|
||||
syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath,
|
||||
needsDownload = true, needsUpload = true,
|
||||
onProgress = onSyncProgress,
|
||||
onByteProgress = onByteSyncProgress,
|
||||
) {
|
||||
if (backend == "local") {
|
||||
val args = mutableListOf(
|
||||
"forget",
|
||||
"--keep-daily", keepDaily.toString(),
|
||||
@@ -90,11 +112,30 @@ class ResticSnapshotOps(
|
||||
)
|
||||
if (dryRun) args.add("--dry-run")
|
||||
|
||||
val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir)
|
||||
val env = envResolver.buildLocalEnv(repoPath, password, cacheDir)
|
||||
val result = runner.runRestic(env, args)
|
||||
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic forget 失败", result.exitCode, result.stderr))
|
||||
} else {
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val args = mutableListOf(
|
||||
"forget",
|
||||
"--keep-daily", keepDaily.toString(),
|
||||
"--keep-weekly", keepWeekly.toString(),
|
||||
"--keep-monthly", keepMonthly.toString()
|
||||
)
|
||||
if (dryRun) args.add("--dry-run")
|
||||
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
val result = runner.runRestic(env, args)
|
||||
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic forget 失败", result.exitCode, result.stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerialName
|
||||
@@ -18,13 +19,14 @@ import com.example.androidbackupgui.backup.err
|
||||
* Uses environment variables (RESTIC_REPOSITORY, RESTIC_PASSWORD) rather than
|
||||
* command-line flags to avoid leaking secrets in the process list.
|
||||
*
|
||||
* For SMB/WebDAV backends, restic runs against a local temp directory;
|
||||
* RemoteTransport syncs files to/from the remote backend.
|
||||
* For SMB/WebDAV backends, restic connects via a local REST bridge
|
||||
* ([ResticRestBridge]) that translates HTTP requests to [RemoteTransport] calls,
|
||||
* eliminating the need for a local staging repo and full-directory sync.
|
||||
*
|
||||
* All public methods are suspend and run on Dispatchers.IO.
|
||||
*
|
||||
* This object is a facade that delegates to [ResticCommandRunner],
|
||||
* [ResticEnvResolver], [RemoteSyncManager], and sub-module classes
|
||||
* [ResticEnvResolver], [RestBridgeRunner], and sub-module classes
|
||||
* ([ResticRepoInit], [ResticBackup], [ResticRestore], [ResticSnapshotOps],
|
||||
* [ResticMaintenance]).
|
||||
*/
|
||||
@@ -34,15 +36,15 @@ object ResticWrapper {
|
||||
|
||||
private val runner = ResticCommandRunner()
|
||||
private val envResolver = ResticEnvResolver()
|
||||
private val syncManager = RemoteSyncManager()
|
||||
private val bridgeRunner = RestBridgeRunner()
|
||||
|
||||
// ── Sub-module instances ───────────────────────────
|
||||
|
||||
private val repoInit = ResticRepoInit(runner, envResolver, syncManager)
|
||||
private val backupOp = ResticBackup(runner, envResolver, syncManager)
|
||||
private val restoreOp = ResticRestore(runner, envResolver, syncManager)
|
||||
private val snapshotOps = ResticSnapshotOps(runner, envResolver, syncManager)
|
||||
private val maintenance = ResticMaintenance(runner, envResolver, syncManager)
|
||||
private val repoInit = ResticRepoInit(runner, envResolver, bridgeRunner)
|
||||
private val backupOp = ResticBackup(runner, envResolver, bridgeRunner)
|
||||
private val restoreOp = ResticRestore(runner, envResolver, bridgeRunner)
|
||||
private val snapshotOps = ResticSnapshotOps(runner, envResolver, bridgeRunner)
|
||||
private val maintenance = ResticMaintenance(runner, envResolver, bridgeRunner)
|
||||
|
||||
// ── Property delegation ───────────────────────────
|
||||
|
||||
@@ -51,16 +53,28 @@ object ResticWrapper {
|
||||
get() = runner.binaryPath
|
||||
set(v) { runner.binaryPath = v }
|
||||
|
||||
/** Local temp directory used as restic repo for SMB/WebDAV backends. */
|
||||
var tempRepoDir: String
|
||||
get() = syncManager.tempRepoDir
|
||||
set(v) { syncManager.tempRepoDir = v }
|
||||
/** Cache directory for restic (XDG_CACHE_HOME) and bridge tmp blobs. */
|
||||
var cacheDir: String = ""
|
||||
set(v) {
|
||||
field = v
|
||||
repoInit.cacheDir = v
|
||||
backupOp.cacheDir = v
|
||||
restoreOp.cacheDir = v
|
||||
snapshotOps.cacheDir = v
|
||||
maintenance.cacheDir = v
|
||||
}
|
||||
|
||||
/** Domain for SMB NTLM authentication. */
|
||||
var backendDomain: String
|
||||
get() = syncManager.backendDomain
|
||||
set(v) { syncManager.backendDomain = v }
|
||||
|
||||
/** Domain for SMB NTLM authentication. Propagated to sub-modules. */
|
||||
var backendDomain: String = ""
|
||||
set(v) {
|
||||
field = v
|
||||
repoInit.backendDomain = v
|
||||
backupOp.backendDomain = v
|
||||
restoreOp.backendDomain = v
|
||||
snapshotOps.backendDomain = v
|
||||
maintenance.backendDomain = v
|
||||
}
|
||||
// ── Progress data ─────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
@@ -84,6 +98,13 @@ object ResticWrapper {
|
||||
val hostname: String = ""
|
||||
)
|
||||
|
||||
/** App metadata read from a restic snapshot for change detection. */
|
||||
data class SnapshotAppInfo(
|
||||
val label: String,
|
||||
val isSystem: Boolean,
|
||||
val apkSizes: List<Long> = emptyList()
|
||||
)
|
||||
|
||||
// ── Repository lifecycle ─────────────────────────
|
||||
|
||||
suspend fun init(
|
||||
@@ -94,11 +115,8 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): AppResult<Unit> = repoInit.init(
|
||||
repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
|
||||
// ── Backup ─────────────────────────────────────────
|
||||
@@ -132,13 +150,32 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
onProgress: suspend (ResticProgress) -> Unit = {}
|
||||
): AppResult<BackupSummary> = backupOp.backup(
|
||||
repoPath, password, paths, tags, hostname,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress, onProgress
|
||||
onProgress
|
||||
)
|
||||
|
||||
// ── Streaming backup (stdin) ─────────────────────
|
||||
|
||||
suspend fun backupStdin(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
stdinFile: File,
|
||||
extraPaths: List<String>,
|
||||
tags: List<String> = emptyList(),
|
||||
hostname: String? = null,
|
||||
backend: String = "local",
|
||||
backendUrl: String = "",
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onProgress: suspend (ResticProgress) -> Unit = {}
|
||||
): AppResult<BackupSummary> = backupOp.backupStdin(
|
||||
repoPath, password, stdinFile, extraPaths, tags, hostname,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onProgress
|
||||
)
|
||||
|
||||
// ── Restore ────────────────────────────────────────
|
||||
@@ -154,13 +191,11 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
onProgress: suspend (String) -> Unit = {}
|
||||
): AppResult<Unit> = restoreOp.restore(
|
||||
repoPath, password, snapshotId, targetPath, include,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress, onProgress
|
||||
onProgress
|
||||
)
|
||||
|
||||
// ── File dump ──────────────────────────────────────
|
||||
@@ -175,12 +210,9 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): AppResult<String> = restoreOp.dump(
|
||||
repoPath, password, snapshotId, filePath,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
|
||||
// ── Snapshot management ────────────────────────────
|
||||
@@ -194,12 +226,9 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): AppResult<List<ResticSnapshot>> = snapshotOps.listSnapshots(
|
||||
repoPath, password, tag,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
|
||||
suspend fun forget(
|
||||
@@ -214,14 +243,81 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): AppResult<String> = snapshotOps.forget(
|
||||
repoPath, password, keepDaily, keepWeekly, keepMonthly, dryRun,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
|
||||
/**
|
||||
* Read [app_details.json] from the latest restic snapshot and return a map
|
||||
* of package-name → [SnapshotAppInfo]. Returns `null` when no snapshots
|
||||
* exist or the file cannot be read (e.g. first backup, legacy format).
|
||||
*/
|
||||
suspend fun getLatestSnapshotAppDetails(
|
||||
repoPath: String,
|
||||
password: String,
|
||||
backend: String = "local",
|
||||
backendUrl: String = "",
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
): Map<String, SnapshotAppInfo>? = withContext(Dispatchers.IO) {
|
||||
val snapsResult = snapshotOps.listSnapshots(
|
||||
repoPath, password, tag = null,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
val snaps = when (snapsResult) {
|
||||
is AppResult.Failure -> {
|
||||
Log.w(TAG, "getLatestSnapshotAppDetails: listSnapshots failed: ${snapsResult.error.message}")
|
||||
null
|
||||
}
|
||||
is AppResult.Success -> snapsResult.data
|
||||
} ?: return@withContext null
|
||||
|
||||
if (snaps.isEmpty()) return@withContext null
|
||||
|
||||
val latestId = snaps.first().shortId
|
||||
val basePath = snaps.first().paths.firstOrNull()?.trimEnd('/') ?: return@withContext null
|
||||
|
||||
val dumpResult = restoreOp.dump(
|
||||
repoPath, password, latestId, "$basePath/app_details.json",
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
|
||||
val jsonStr = when (dumpResult) {
|
||||
is AppResult.Failure -> return@withContext null
|
||||
is AppResult.Success -> dumpResult.data
|
||||
}
|
||||
|
||||
return@withContext parseAppDetailsJson(jsonStr)
|
||||
}
|
||||
|
||||
/** Parse [app_details.json] content into a package-name → [SnapshotAppInfo] map. */
|
||||
internal fun parseAppDetailsJson(jsonStr: String): Map<String, SnapshotAppInfo> {
|
||||
val map = mutableMapOf<String, SnapshotAppInfo>()
|
||||
try {
|
||||
val root = JSONObject(jsonStr)
|
||||
for (key in root.keys()) {
|
||||
val entry = root.optJSONObject(key) ?: continue
|
||||
val sizes = mutableListOf<Long>()
|
||||
val sizesArr = entry.optJSONArray("apkSizes")
|
||||
if (sizesArr != null) {
|
||||
for (i in 0 until sizesArr.length()) {
|
||||
sizes.add(sizesArr.optLong(i, 0L))
|
||||
}
|
||||
}
|
||||
map[key] = SnapshotAppInfo(
|
||||
label = entry.optString("label", key),
|
||||
isSystem = entry.optBoolean("isSystem", false),
|
||||
apkSizes = sizes
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
Log.w(TAG, "parseAppDetailsJson: failed to parse JSON")
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ── Maintenance ────────────────────────────────────
|
||||
|
||||
suspend fun prune(
|
||||
@@ -232,12 +328,9 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): AppResult<String> = maintenance.prune(
|
||||
repoPath, password,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
|
||||
suspend fun check(
|
||||
@@ -248,12 +341,9 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): AppResult<String> = maintenance.check(
|
||||
repoPath, password,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
|
||||
suspend fun stats(
|
||||
@@ -264,12 +354,9 @@ object ResticWrapper {
|
||||
backendUser: String = "",
|
||||
backendPass: String = "",
|
||||
backendShare: String = "",
|
||||
onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {},
|
||||
onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {},
|
||||
): AppResult<String> = maintenance.stats(
|
||||
repoPath, password,
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
onSyncProgress, onByteSyncProgress
|
||||
backend, backendUrl, backendUser, backendPass, backendShare
|
||||
)
|
||||
|
||||
// ── Public URL helper ──────────────────────────────
|
||||
@@ -278,14 +365,4 @@ object ResticWrapper {
|
||||
fun buildRepoUrl(backend: String, repoPath: String, backendUrl: String): String {
|
||||
return repoInit.buildRepoUrl(backend, repoPath, backendUrl)
|
||||
}
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Public safety-net cleanup called by fragment lifecycle.
|
||||
* Waits for any in-progress operation to finish, then deletes temp dirs.
|
||||
*/
|
||||
suspend fun cleanup() {
|
||||
syncManager.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* Streaming backup orchestrator.
|
||||
*
|
||||
* Uses a FIFO (named pipe) to pipe app data tar output directly into
|
||||
* `restic backup --stdin`, eliminating the staging directory for large
|
||||
* data backups.
|
||||
*/
|
||||
object StreamingBackup {
|
||||
|
||||
private const val TAG = "StreamingBackup"
|
||||
|
||||
data class StreamingResult(
|
||||
val apkPaths: List<String>, // APK paths (backed up directly by restic)
|
||||
val dataFifo: File, // FIFO path for app data tar
|
||||
val metaDir: File // Metadata directory (~1MB)
|
||||
)
|
||||
|
||||
/**
|
||||
* Prepare streaming backup configuration.
|
||||
*
|
||||
* Creates the FIFO and metadata directory, collects APK paths.
|
||||
*
|
||||
* @param cacheDir Directory to place FIFO and temp files
|
||||
* @param apps List of apps being backed up
|
||||
* @param legacyApps Metadata from previous snapshot
|
||||
*/
|
||||
suspend fun prepareStreaming(
|
||||
cacheDir: File,
|
||||
apps: List<AppInfo>,
|
||||
legacyApps: Map<String, ResticWrapper.SnapshotAppInfo>?
|
||||
): StreamingResult = withContext(Dispatchers.IO) {
|
||||
cacheDir.mkdirs()
|
||||
|
||||
// Create FIFO for data pipe
|
||||
val fifo = File(cacheDir, "app_data_stream.fifo")
|
||||
// Remove stale FIFO if present
|
||||
if (fifo.exists()) fifo.delete()
|
||||
// mkfifo requires root on Android
|
||||
RootShell.exec("mkfifo '${fifo.absolutePath.shellEscape()}'")
|
||||
Log.i(TAG, "FIFO created at ${fifo.absolutePath}")
|
||||
|
||||
// Collect APK paths
|
||||
val apkPaths = mutableListOf<String>()
|
||||
for (app in apps) {
|
||||
val paths = AppScanner.getApkPaths(app.packageName.value)
|
||||
apkPaths.addAll(paths)
|
||||
}
|
||||
|
||||
// Create metadata directory
|
||||
val metaDir = File(cacheDir, "streaming_meta")
|
||||
metaDir.mkdirs()
|
||||
|
||||
// Write app list
|
||||
val appListFile = File(metaDir, "appList.txt")
|
||||
appListFile.writeText(apps.joinToString("\n") { it.packageName.value })
|
||||
|
||||
// Write app_details.json
|
||||
val metaFile = File(metaDir, "app_details.json")
|
||||
metaFile.writeText(BackupOperation.buildAppDetailsJson(apps, legacyApps))
|
||||
|
||||
Log.i(TAG, "Streaming prepared: ${apkPaths.size} APKs, FIFO at ${fifo.absolutePath}")
|
||||
StreamingResult(apkPaths, fifo, metaDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the data producer in a root shell background process.
|
||||
*
|
||||
* For each app, runs `tar -cf - /data/data/pkg 2>/dev/null` and appends
|
||||
* to the FIFO. The FIFO is consumed by `restic backup --stdin`.
|
||||
*
|
||||
* @param apps Apps whose data directories to tar
|
||||
* @param noDataBackup Set of package names to exclude from data backup
|
||||
* @param userId Android user ID
|
||||
* @param fifoPath Path to the FIFO
|
||||
*/
|
||||
suspend fun launchDataProducer(
|
||||
apps: List<AppInfo>,
|
||||
noDataBackup: Set<String>,
|
||||
@Suppress("UNUSED_PARAMETER") userId: String,
|
||||
fifoPath: String
|
||||
): Boolean = withContext(Dispatchers.IO) {
|
||||
val fifoEsc = fifoPath.shellEscape()
|
||||
|
||||
for (app in apps) {
|
||||
if (!coroutineContext.isActive) return@withContext false
|
||||
|
||||
val pkgName = app.packageName.value
|
||||
if (pkgName in noDataBackup) {
|
||||
Log.d(TAG, "Skipping data for $pkgName (excluded)")
|
||||
continue
|
||||
}
|
||||
|
||||
val dataDir = "/data/data/$pkgName"
|
||||
// Check if data directory exists
|
||||
val existsResult = RootShell.exec("[ -d '${dataDir.shellEscape()}' ] && echo 1 || echo 0")
|
||||
if (existsResult.output.trim() != "1") {
|
||||
Log.d(TAG, "No data directory for $pkgName, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
// Append tar output to FIFO. `>>` blocks until consumer reads.
|
||||
val cmd = "tar -cf - '$dataDir' 2>/dev/null >> '$fifoEsc'"
|
||||
Log.d(TAG, "Streaming data for $pkgName: $cmd")
|
||||
val result = RootShell.exec(cmd)
|
||||
if (!result.isSuccess) {
|
||||
Log.w(TAG, "Data backup failed for $pkgName: ${result.error}")
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "Data producer completed")
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.example.androidbackupgui.backup.AppInfo
|
||||
import com.example.androidbackupgui.backup.PackageName
|
||||
import com.example.androidbackupgui.backup.AppScanner
|
||||
import com.example.androidbackupgui.backup.BackupConfig
|
||||
import com.example.androidbackupgui.backup.BackupOperation
|
||||
@@ -20,11 +21,16 @@ import com.example.androidbackupgui.backup.ResticBinary
|
||||
import com.example.androidbackupgui.backup.ResticWrapper
|
||||
import com.example.androidbackupgui.backup.WifiManager
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.backup.RemoteTransport
|
||||
import com.example.androidbackupgui.databinding.FragmentBackupBinding
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import android.os.StatFs
|
||||
import com.example.androidbackupgui.backup.StreamingBackup
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import com.example.androidbackupgui.backup.formatSize
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
@@ -41,6 +47,7 @@ class BackupFragment : Fragment() {
|
||||
private var userList: List<Pair<Int, String>> = listOf(0 to "Owner")
|
||||
private var sortMode: SortMode = SortMode.NAME_ASC
|
||||
private var showSystemApps: Boolean = false
|
||||
private var excludeDataFromBackup = mutableSetOf<String>()
|
||||
|
||||
private enum class SortMode { NAME_ASC, SIZE_DESC }
|
||||
|
||||
@@ -56,10 +63,12 @@ class BackupFragment : Fragment() {
|
||||
|
||||
val configFile = File(requireContext().filesDir, "backup_settings.conf")
|
||||
config = BackupConfig.fromFile(configFile)
|
||||
updateOutputPathDisplay()
|
||||
|
||||
binding.appList.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
||||
binding.scanButton.setOnClickListener { scanApps() }
|
||||
binding.outputPathEdit.setOnClickListener { showOutputPathEditDialog() }
|
||||
binding.backupButton.setOnClickListener { startBackup() }
|
||||
|
||||
// Sort/filter controls
|
||||
@@ -110,6 +119,11 @@ class BackupFragment : Fragment() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (::config.isInitialized) {
|
||||
val configFile = File(requireContext().filesDir, "backup_settings.conf")
|
||||
config = BackupConfig.fromFile(configFile)
|
||||
updateOutputPathDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scanApps() {
|
||||
@@ -149,27 +163,25 @@ class BackupFragment : Fragment() {
|
||||
setupAppList()
|
||||
binding.statusText.text = "已选择 ${selectedApps.size}/${sortedApps.size} 个应用"
|
||||
}
|
||||
|
||||
private fun setupAppList() {
|
||||
val displayApps = sortedApps.ifEmpty { apps }
|
||||
binding.appList.adapter = PackageListAdapter(displayApps, selectedApps) { pkg, checked ->
|
||||
if (checked) selectedApps.add(pkg) else selectedApps.remove(pkg)
|
||||
binding.statusText.text = "已选择 ${selectedApps.size}/${displayApps.size} 个应用"
|
||||
}
|
||||
binding.appList.adapter = PackageListAdapter(
|
||||
displayApps, selectedApps,
|
||||
onToggle = { pkg, checked ->
|
||||
if (checked) selectedApps.add(pkg) else selectedApps.remove(pkg)
|
||||
binding.statusText.text = "已选择 ${selectedApps.size}/${displayApps.size} 个应用"
|
||||
},
|
||||
excludeDataFrom = excludeDataFromBackup,
|
||||
onExcludeDataToggle = { pkg, excluded ->
|
||||
if (excluded) excludeDataFromBackup.add(pkg) else excludeDataFromBackup.remove(pkg)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun startBackup() {
|
||||
val toBackup = apps.filter { it.packageName.value in selectedApps }
|
||||
if (toBackup.isEmpty()) return
|
||||
|
||||
// Check restic local repo availability before doing any work
|
||||
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank() &&
|
||||
config.resticBackend == "local" && !File(config.resticRepo, "config").exists()
|
||||
) {
|
||||
binding.statusText.text = "restic 本地仓库未初始化,请先在设置中初始化"
|
||||
return
|
||||
}
|
||||
|
||||
setRunning(true)
|
||||
binding.backupButton.isEnabled = false
|
||||
binding.scanButton.isEnabled = false
|
||||
@@ -187,14 +199,108 @@ class BackupFragment : Fragment() {
|
||||
val outputDir = File(config.outputPath.ifEmpty {
|
||||
requireContext().filesDir.absolutePath
|
||||
})
|
||||
|
||||
// ── Restic pre-flight: load snapshot metadata for cumulative merge ──
|
||||
var snapshotApps: Map<String, ResticWrapper.SnapshotAppInfo>? = null
|
||||
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
|
||||
updateStatus("正在检查 restic 历史快照…")
|
||||
|
||||
if (config.resticBackend == "local" && !File(config.resticRepo, "config").exists()) {
|
||||
updateStatus("restic 本地仓库未初始化,请先在设置中初始化")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val binaryPath = ResticBinary.prepare(requireContext())
|
||||
if (binaryPath != null) {
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.cacheDir = requireContext().cacheDir.absolutePath
|
||||
ResticWrapper.backendDomain = config.resticBackendDomain
|
||||
|
||||
snapshotApps = ResticWrapper.getLatestSnapshotAppDetails(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare
|
||||
)
|
||||
if (snapshotApps != null) {
|
||||
updateStatus("发现历史快照,将合并为累积备份")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build merged app list for cumulative snapshot ──
|
||||
val selectedPkgs = toBackup.map { it.packageName.value }.toSet()
|
||||
val allApps: List<AppInfo>
|
||||
val includePkgs: Set<String>
|
||||
|
||||
if (snapshotApps != null) {
|
||||
// Create placeholder AppInfo entries for packages from the snapshot
|
||||
// that are NOT in the current selection. These won't be re-backed-up
|
||||
// but their metadata is preserved via legacyApps.
|
||||
val snapshotOnly = snapshotApps.keys.filter { it !in selectedPkgs }
|
||||
val legacyEntries = snapshotOnly.mapNotNull { pkg ->
|
||||
val snap = snapshotApps[pkg] ?: return@mapNotNull null
|
||||
AppInfo(
|
||||
packageName = PackageName(pkg),
|
||||
label = snap.label,
|
||||
isSystem = snap.isSystem
|
||||
)
|
||||
}
|
||||
allApps = toBackup + legacyEntries
|
||||
includePkgs = selectedPkgs
|
||||
val snapCount = legacyEntries.size
|
||||
if (snapCount > 0) {
|
||||
updateStatus("累积备份: ${allApps.size} 个应用 ($snapCount 个来自历史快照)")
|
||||
}
|
||||
|
||||
// Restore latest snapshot to populate directories for unchanged apps
|
||||
updateStatus("正在恢复历史快照…")
|
||||
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_${selectedUserId}")
|
||||
backupRoot.mkdirs()
|
||||
val snapsResult = ResticWrapper.listSnapshots(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare
|
||||
)
|
||||
val latestSnap = (snapsResult as? AppResult.Success)?.data?.firstOrNull()
|
||||
if (latestSnap != null) {
|
||||
ResticWrapper.restore(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
snapshotId = latestSnap.shortId,
|
||||
targetPath = backupRoot.absolutePath,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare
|
||||
)
|
||||
}
|
||||
} else {
|
||||
allApps = toBackup
|
||||
includePkgs = emptySet()
|
||||
}
|
||||
|
||||
// ── Execute backup (with cumulative metadata) ──
|
||||
updateStatus("正在备份: ${allApps.size} 个应用…")
|
||||
val result = BackupOperation.backupApps(
|
||||
context = requireContext(),
|
||||
apps = toBackup,
|
||||
apps = allApps,
|
||||
config = config,
|
||||
outputDir = outputDir,
|
||||
userId = selectedUserId.toString(),
|
||||
noDataBackup = excludeDataFromBackup.toSet(),
|
||||
includePkgs = includePkgs,
|
||||
legacyApps = snapshotApps,
|
||||
onProgress = { progress ->
|
||||
val label = toBackup.find { it.packageName.value == progress.packageName }?.label
|
||||
val label = allApps.find { it.packageName.value == progress.packageName }?.label
|
||||
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
|
||||
updateStatus("[${progress.current}/${progress.total}] $name: ${progress.message}")
|
||||
}
|
||||
@@ -210,7 +316,7 @@ class BackupFragment : Fragment() {
|
||||
val binaryPath = ResticBinary.prepare(requireContext())
|
||||
if (binaryPath != null) {
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
|
||||
ResticWrapper.cacheDir = requireContext().cacheDir.absolutePath
|
||||
ResticWrapper.backendDomain = config.resticBackendDomain
|
||||
|
||||
if (config.resticBackend == "local") {
|
||||
@@ -231,19 +337,6 @@ class BackupFragment : Fragment() {
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
onSyncProgress = { progress: RemoteTransport.TransferProgress ->
|
||||
if (progress.phase in listOf("list", "download", "upload", "delete_stale")) {
|
||||
updateStatus("同步中: ${progress.current}/${progress.total} 个文件")
|
||||
}
|
||||
},
|
||||
onByteSyncProgress = { progress ->
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
|
||||
binding.progressBar.progress = progress.bytesTransferred.toInt()
|
||||
}
|
||||
updateStatus("同步中: ${progress.currentFile}\n" +
|
||||
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}")
|
||||
},
|
||||
onProgress = { progress ->
|
||||
if (progress.messageType == "status") {
|
||||
updateStatus("去重仓库: %.0f%% (%d/%d 个文件)".format(
|
||||
@@ -259,16 +352,17 @@ class BackupFragment : Fragment() {
|
||||
is AppResult.Failure -> {
|
||||
resticError = resticResult.error.message
|
||||
updateStatus("restic 快照失败: ${resticResult.error.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus(buildString {
|
||||
appendLine("备份完成!")
|
||||
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
|
||||
appendLine("耗时: ${result.elapsedMs / 1000}秒")
|
||||
appendLine("输出: ${result.outputDir}")
|
||||
appendLine("模式: 累积快照")
|
||||
val summary = resticSummary
|
||||
if (summary != null) {
|
||||
appendLine()
|
||||
@@ -312,13 +406,142 @@ class BackupFragment : Fragment() {
|
||||
withContext(Dispatchers.Main) { binding.statusText.text = text }
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
ResticWrapper.cleanup()
|
||||
private fun updateOutputPathDisplay() {
|
||||
val path = config.outputPath.ifEmpty { requireContext().filesDir.absolutePath }
|
||||
binding.outputPathLabel.text = path
|
||||
}
|
||||
|
||||
|
||||
|
||||
private fun showOutputPathEditDialog() {
|
||||
val editText = android.widget.EditText(requireContext()).apply {
|
||||
setText(config.outputPath)
|
||||
hint = requireContext().filesDir.absolutePath
|
||||
}
|
||||
com.google.android.material.dialog.MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("修改输出目录")
|
||||
.setView(editText)
|
||||
.setPositiveButton("确定") { _, _ ->
|
||||
val newPath = editText.text.toString().trim()
|
||||
config = config.copy(outputPath = newPath)
|
||||
BackupConfig.toFile(config, File(requireContext().filesDir, "backup_settings.conf"))
|
||||
updateOutputPathDisplay()
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
// ── Space detection & streaming backup ────────────
|
||||
|
||||
/**
|
||||
* Estimate the total size of data to back up using `du -sb`.
|
||||
* Only counts data directories (not APKs) since that's the bulk.
|
||||
*/
|
||||
private suspend fun estimateBackupSize(apps: List<com.example.androidbackupgui.backup.AppInfo>): Long = withContext(Dispatchers.IO) {
|
||||
var total = 0L
|
||||
for (app in apps) {
|
||||
val pkgEsc = app.packageName.value.shellEscape()
|
||||
val result = RootShell.exec("du -sb /data/data/$pkgEsc 2>/dev/null | cut -f1")
|
||||
val size = result.output.trim().toLongOrNull() ?: 0L
|
||||
total += size
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if [path] has at least [neededBytes] bytes free.
|
||||
* Uses [StatFs] to query the filesystem.
|
||||
*/
|
||||
private fun hasEnoughSpace(path: File, neededBytes: Long): Boolean {
|
||||
try {
|
||||
val stat = StatFs(path.absolutePath)
|
||||
val available = stat.availableBlocksLong * stat.blockSizeLong
|
||||
// Require 1.5x headroom for temp files and metadata
|
||||
return available >= neededBytes * 3 / 2
|
||||
} catch (_: Exception) {
|
||||
// If we can't check, assume enough space (staging mode)
|
||||
return true
|
||||
}
|
||||
}
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Run streaming backup via [StreamingBackup] + [ResticWrapper.backupStdin].
|
||||
* Used when staging space is insufficient.
|
||||
*/
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
private suspend fun runStreamingResticBackup(
|
||||
config: com.example.androidbackupgui.backup.BackupConfig,
|
||||
apps: List<com.example.androidbackupgui.backup.AppInfo>,
|
||||
outputDir: File,
|
||||
cacheDir: String
|
||||
): ResticWrapper.BackupSummary? {
|
||||
updateStatus("空间不足,启动流式备份模式…")
|
||||
|
||||
val cacheDirFile = File(cacheDir, "streaming_tmp")
|
||||
cacheDirFile.mkdirs()
|
||||
|
||||
// Prepare streaming: create FIFO, metadata, collect APK paths
|
||||
val streamingResult = StreamingBackup.prepareStreaming(
|
||||
cacheDirFile, apps, null
|
||||
)
|
||||
|
||||
// Start restic with stdin from FIFO, in parallel with data producer
|
||||
var summary: ResticWrapper.BackupSummary? = null
|
||||
var backupError: String? = null
|
||||
|
||||
coroutineScope {
|
||||
// Launch restic backup (consumer)
|
||||
val resticJob = async {
|
||||
val result = ResticWrapper.backupStdin(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
stdinFile = streamingResult.dataFifo,
|
||||
extraPaths = streamingResult.apkPaths + streamingResult.metaDir.absolutePath,
|
||||
tags = listOf("streaming_${System.currentTimeMillis() / 1000}"),
|
||||
hostname = "android-backup-gui",
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
onProgress = { progress ->
|
||||
if (progress.messageType == "status") {
|
||||
updateStatus("流式去重仓库: %.0f%% (%d/%d 个文件)".format(
|
||||
progress.percentDone * 100,
|
||||
progress.filesDone,
|
||||
progress.totalFiles
|
||||
))
|
||||
}
|
||||
}
|
||||
)
|
||||
when (result) {
|
||||
is AppResult.Success -> summary = result.data
|
||||
is AppResult.Failure -> backupError = result.error.message
|
||||
}
|
||||
}
|
||||
|
||||
// Launch data producer (writes tar to FIFO)
|
||||
val producerJob = async {
|
||||
StreamingBackup.launchDataProducer(
|
||||
apps = apps,
|
||||
noDataBackup = excludeDataFromBackup.toSet(),
|
||||
userId = selectedUserId.toString(),
|
||||
fifoPath = streamingResult.dataFifo.absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for both to complete
|
||||
producerJob.await()
|
||||
resticJob.await()
|
||||
}
|
||||
|
||||
// Cleanup FIFO
|
||||
try { streamingResult.dataFifo.delete() } catch (_: Exception) {}
|
||||
try { streamingResult.metaDir.deleteRecursively() } catch (_: Exception) {}
|
||||
|
||||
if (backupError != null) {
|
||||
updateStatus("流式备份失败: $backupError")
|
||||
}
|
||||
return summary
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,13 +247,4 @@ class ConfigFragment : Fragment() {
|
||||
vm.pruneResticSnapshots(readResticForm())
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
ResticWrapper.cleanup()
|
||||
}
|
||||
}
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import com.example.androidbackupgui.backup.BackupConfig
|
||||
import com.example.androidbackupgui.backup.formatSize
|
||||
import com.example.androidbackupgui.backup.ResticBinary
|
||||
import com.example.androidbackupgui.backup.ResticWrapper
|
||||
import com.example.androidbackupgui.backup.RemoteTransport
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -18,7 +17,6 @@ import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
@@ -154,7 +152,7 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
val binaryPath = ResticBinary.prepare(ctx)
|
||||
if (binaryPath == null) return false
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(ctx)
|
||||
ResticWrapper.cacheDir = ctx.cacheDir.absolutePath
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -188,8 +186,6 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
onSyncProgress = { p -> onSyncProgress(p) },
|
||||
onByteSyncProgress = { p -> onByteProgress(p) },
|
||||
)
|
||||
if (result.isSuccess) {
|
||||
_operationEvents.emit(OperationEvent.InitCompleted)
|
||||
@@ -235,8 +231,6 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
onSyncProgress = { p -> onSyncProgress(p) },
|
||||
onByteSyncProgress = { p -> onByteProgress(p) },
|
||||
)
|
||||
if (snapshotsResult.isSuccess) {
|
||||
val snapshots = snapshotsResult.getOrDefault(emptyList())
|
||||
@@ -265,15 +259,11 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
onSyncProgress = { p -> onSyncProgress(p) },
|
||||
onByteSyncProgress = { p -> onByteProgress(p) },
|
||||
)
|
||||
val snapshotsResult = ResticWrapper.listSnapshots(form.repo, form.password,
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
onSyncProgress = { p -> onSyncProgress(p) },
|
||||
onByteSyncProgress = { p -> onByteProgress(p) },
|
||||
)
|
||||
|
||||
val snapshotCount = snapshotsResult.getOrDefault(emptyList()).size
|
||||
@@ -310,8 +300,6 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
onSyncProgress = { p -> onSyncProgress(p) },
|
||||
onByteSyncProgress = { p -> onByteProgress(p) },
|
||||
)
|
||||
if (forgetResult.isFailure) {
|
||||
_operationEvents.emit(OperationEvent.PruneFailed)
|
||||
@@ -328,8 +316,6 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
backend = form.backend, backendUrl = form.backendUrl,
|
||||
backendUser = form.backendUser, backendPass = form.backendPass,
|
||||
backendShare = form.backendShare,
|
||||
onSyncProgress = { p -> onSyncProgress(p) },
|
||||
onByteSyncProgress = { p -> onByteProgress(p) },
|
||||
)
|
||||
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = if (pruneResult.isSuccess)
|
||||
@@ -349,29 +335,5 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal progress helpers ─────────────────────────────────────
|
||||
|
||||
private fun onSyncProgress(p: RemoteTransport.TransferProgress) {
|
||||
_uiState.update {
|
||||
it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "同步中: ${p.current}/${p.total} 个文件"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private fun onByteProgress(p: RemoteTransport.ByteProgress) {
|
||||
_uiState.update {
|
||||
it.copy(resticStatus = it.resticStatus.copy(
|
||||
message = "同步中: ${p.currentFile}\n${formatSize(p.bytesTransferred)} / ${formatSize(p.totalBytes)}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/** Cleanup ResticWrapper resources when ViewModel is cleared. */
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
runBlocking(Dispatchers.IO) {
|
||||
ResticWrapper.cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,15 @@ import com.google.android.material.color.MaterialColors
|
||||
class PackageListAdapter(
|
||||
private val apps: List<AppInfo>,
|
||||
private val selected: Set<String>,
|
||||
private val onToggle: (String, Boolean) -> Unit
|
||||
private val onToggle: (String, Boolean) -> Unit,
|
||||
private val excludeDataFrom: Set<String> = emptySet(),
|
||||
private val onExcludeDataToggle: ((String, Boolean) -> Unit)? = null
|
||||
) : RecyclerView.Adapter<PackageListAdapter.ViewHolder>() {
|
||||
|
||||
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val checkbox: CheckBox = view.findViewById(R.id.checkbox)
|
||||
val textView: TextView = view.findViewById(R.id.appName)
|
||||
val excludeToggle: TextView = view.findViewById(R.id.excludeToggle)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
@@ -55,21 +58,67 @@ class PackageListAdapter(
|
||||
MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, 0)
|
||||
)
|
||||
}
|
||||
val et = TextView(ctx).apply {
|
||||
id = R.id.excludeToggle
|
||||
visibility = if (onExcludeDataToggle != null) View.VISIBLE else View.GONE
|
||||
setPadding(res.getDimensionPixelSize(R.dimen.card_padding_horizontal), 0, 0, 0)
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_PX, res.getDimension(R.dimen.list_item_text_size) * 0.75f)
|
||||
setTextColor(
|
||||
MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurfaceVariant, 0)
|
||||
)
|
||||
}
|
||||
layout.addView(cb)
|
||||
layout.addView(tv)
|
||||
layout.addView(et)
|
||||
card.addView(layout)
|
||||
return ViewHolder(card)
|
||||
|
||||
val holder = ViewHolder(card)
|
||||
card.setOnClickListener {
|
||||
val pos = holder.adapterPosition
|
||||
if (pos == RecyclerView.NO_POSITION) return@setOnClickListener
|
||||
val app = apps[pos]
|
||||
val newChecked = !holder.checkbox.isChecked
|
||||
// Temporarily suppress checkbox listener to avoid double-fire
|
||||
holder.checkbox.setOnCheckedChangeListener(null)
|
||||
holder.checkbox.isChecked = newChecked
|
||||
holder.checkbox.setOnCheckedChangeListener { _, checked ->
|
||||
onToggle(app.packageName.value, checked)
|
||||
}
|
||||
onToggle(app.packageName.value, newChecked)
|
||||
}
|
||||
return holder
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val app = apps[position]
|
||||
val pkg = app.packageName.value
|
||||
// Prefer app name (label), fall back to package name
|
||||
holder.textView.text = app.label.ifEmpty { app.packageName.value }
|
||||
holder.textView.text = app.label.ifEmpty { pkg }
|
||||
// Avoid re-triggering listener during bind
|
||||
holder.checkbox.setOnCheckedChangeListener(null)
|
||||
holder.checkbox.isChecked = app.packageName.value in selected
|
||||
holder.checkbox.isChecked = pkg in selected
|
||||
holder.checkbox.setOnCheckedChangeListener { _, checked ->
|
||||
onToggle(app.packageName.value, checked)
|
||||
onToggle(pkg, checked)
|
||||
}
|
||||
// Configure per-app data exclusion toggle
|
||||
val toggle = holder.excludeToggle
|
||||
val dataToggleCb = onExcludeDataToggle
|
||||
if (dataToggleCb != null) {
|
||||
toggle.visibility = View.VISIBLE
|
||||
val excluded = pkg in excludeDataFrom
|
||||
toggle.text = "数据"
|
||||
toggle.paintFlags = if (excluded) {
|
||||
toggle.paintFlags or android.graphics.Paint.STRIKE_THRU_TEXT_FLAG
|
||||
} else {
|
||||
toggle.paintFlags and android.graphics.Paint.STRIKE_THRU_TEXT_FLAG.inv()
|
||||
}
|
||||
toggle.isSelected = excluded
|
||||
toggle.setOnClickListener {
|
||||
dataToggleCb(pkg, !excluded)
|
||||
}
|
||||
} else {
|
||||
toggle.visibility = View.GONE
|
||||
toggle.setOnClickListener(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,13 +18,11 @@ import com.example.androidbackupgui.backup.RestoreOperation
|
||||
import com.example.androidbackupgui.backup.ResticBinary
|
||||
import com.example.androidbackupgui.backup.ResticWrapper
|
||||
import com.example.androidbackupgui.backup.WifiManager
|
||||
import com.example.androidbackupgui.backup.RemoteTransport
|
||||
import com.example.androidbackupgui.databinding.FragmentRestoreBinding
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import com.example.androidbackupgui.backup.formatSize
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
@@ -64,7 +62,7 @@ class RestoreFragment : Fragment() {
|
||||
val binaryPath = ResticBinary.prepare(requireContext())
|
||||
if (binaryPath != null) {
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
|
||||
ResticWrapper.cacheDir = requireContext().cacheDir.absolutePath
|
||||
ResticWrapper.backendDomain = config.resticBackendDomain
|
||||
binding.selectResticButton.visibility = View.VISIBLE
|
||||
}
|
||||
@@ -120,15 +118,14 @@ class RestoreFragment : Fragment() {
|
||||
// Skip redundant preparation if binary and backend config are already set
|
||||
if (resticConfig != null &&
|
||||
ResticWrapper.binaryPath.isNotEmpty() &&
|
||||
ResticWrapper.binaryPath != "restic" &&
|
||||
ResticWrapper.backendDomain == config.resticBackendDomain
|
||||
ResticWrapper.binaryPath != "restic"
|
||||
) {
|
||||
binding.selectResticButton.visibility = View.VISIBLE
|
||||
} else {
|
||||
val binaryPath = ResticBinary.prepare(requireContext())
|
||||
if (binaryPath != null && resticConfig != null) {
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
|
||||
ResticWrapper.cacheDir = requireContext().cacheDir.absolutePath
|
||||
ResticWrapper.backendDomain = config.resticBackendDomain
|
||||
binding.selectResticButton.visibility = View.VISIBLE
|
||||
}
|
||||
@@ -189,12 +186,6 @@ class RestoreFragment : Fragment() {
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
onSyncProgress = { p ->
|
||||
updateStatus("同步中: ${p.current}/${p.total} [${p.currentFile}]")
|
||||
},
|
||||
onByteSyncProgress = { bp ->
|
||||
updateStatus("下载中: ${bp.bytesTransferred / 1024 / 1024} MB / ${bp.totalBytes / 1024 / 1024} MB")
|
||||
}
|
||||
)
|
||||
if (snapshotsResult.isFailure) {
|
||||
updateStatus("读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}")
|
||||
@@ -292,10 +283,13 @@ class RestoreFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun setupAppList() {
|
||||
binding.appList.adapter = PackageListAdapter(appInfos, selectedPackages) { pkg, checked ->
|
||||
if (checked) selectedPackages.add(pkg) else selectedPackages.remove(pkg)
|
||||
binding.statusText.text = "已选择 ${selectedPackages.size}/${packages.size} 个应用"
|
||||
}
|
||||
binding.appList.adapter = PackageListAdapter(
|
||||
appInfos, selectedPackages,
|
||||
onToggle = { pkg, checked ->
|
||||
if (checked) selectedPackages.add(pkg) else selectedPackages.remove(pkg)
|
||||
binding.statusText.text = "已选择 ${selectedPackages.size}/${packages.size} 个应用"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun startRestore() {
|
||||
@@ -329,19 +323,6 @@ class RestoreFragment : Fragment() {
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
onSyncProgress = { progress: RemoteTransport.TransferProgress ->
|
||||
if (progress.phase in listOf("list", "download", "upload", "delete_stale")) {
|
||||
updateStatus("同步中: ${progress.current}/${progress.total} 个文件")
|
||||
}
|
||||
},
|
||||
onByteSyncProgress = { progress ->
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
|
||||
binding.progressBar.progress = progress.bytesTransferred.toInt()
|
||||
}
|
||||
updateStatus("同步中: ${progress.currentFile}\n" +
|
||||
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}")
|
||||
},
|
||||
onProgress = { msg -> withContext(Dispatchers.Main) { binding.statusText.text = msg } }
|
||||
)
|
||||
|
||||
|
||||
@@ -50,6 +50,39 @@
|
||||
android:layout_weight="1" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="输出目录: "
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/outputPathLabel"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="middle"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/outputPathEdit"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="修改" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<resources>
|
||||
<item name="checkbox" type="id" />
|
||||
<item name="appName" type="id" />
|
||||
<item name="excludeToggle" type="id" />
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user