Compare commits
21 Commits
v1.1
...
v1.2-debug
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bde3b0a75 | ||
|
|
d2ea9f532f | ||
|
|
ac0fd8b063 | ||
|
|
fde6d05b83 | ||
|
|
2ff096ee8a | ||
|
|
eae7f4b369 | ||
|
|
420d960ce1 | ||
|
|
88e81e956c | ||
|
|
c12f7a9c81 | ||
|
|
a43638698c | ||
|
|
12fe29f841 | ||
|
|
b7addcca6b | ||
|
|
98d4029fd4 | ||
|
|
07366f744f | ||
|
|
88e18f4c57 | ||
|
|
d8992c3931 | ||
|
|
c7e9d8b5f1 | ||
|
|
3e1f8a5937 | ||
|
|
80b84f6cff | ||
|
|
0b017e853b | ||
|
|
2351cce99e |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -16,3 +16,9 @@ Thumbs.db
|
||||
# Keystore (regenerate if needed)
|
||||
debug.keystore
|
||||
release.keystore
|
||||
|
||||
# Memory files from agent harness
|
||||
memory:*
|
||||
|
||||
# Restic test repository (contains encryption keys)
|
||||
test/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **android-backup-gui** (933 symbols, 2388 relationships, 80 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** (922 symbols, 2334 relationships, 79 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** (933 symbols, 2388 relationships, 80 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** (922 symbols, 2334 relationships, 79 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.
|
||||
|
||||
|
||||
@@ -20,17 +20,22 @@ android {
|
||||
}
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile file("release.keystore")
|
||||
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "android"
|
||||
keyAlias "release"
|
||||
keyPassword System.getenv("KEY_PASSWORD") ?: "android"
|
||||
def keystoreFile = file("release.keystore")
|
||||
if (keystoreFile.exists()) {
|
||||
storeFile keystoreFile
|
||||
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "android"
|
||||
keyAlias "release"
|
||||
keyPassword System.getenv("KEY_PASSWORD") ?: "android"
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
signingConfig signingConfigs.release
|
||||
if (file("release.keystore").exists()) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
@@ -64,4 +69,7 @@ dependencies {
|
||||
implementation "eu.agno3.jcifs:jcifs-ng:2.1.10"
|
||||
implementation "com.github.thegrizzlylabs:sardine-android:v0.9"
|
||||
implementation "org.slf4j:slf4j-android:1.7.36"
|
||||
|
||||
// root shell via libsu (Magisk/KernelSU/APatch)
|
||||
implementation 'com.github.topjohnwu:libsu:6.0.0'
|
||||
}
|
||||
|
||||
@@ -159,43 +159,93 @@ object BackupOperation {
|
||||
compression: String
|
||||
): Boolean {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
val dataDir = "/data/data/$pkgEsc"
|
||||
val userDeDir = "/data/user_de/${userId.shellEscape()}/$pkgEsc"
|
||||
val outputFile = "${appDir.absolutePath.shellEscape()}/${pkgEsc}_data.tar"
|
||||
// Build a list of dirs that exist
|
||||
val dirs = mutableListOf<String>()
|
||||
if (RootShell.exec("test -d $dataDir").isSuccess) dirs.add(dataDir)
|
||||
if (RootShell.exec("test -d $userDeDir").isSuccess) dirs.add(userDeDir)
|
||||
if (dirs.isEmpty()) return true // no data to backup is not an error
|
||||
// Exclude cache, code_cache, lib
|
||||
val isZstd = compression == "zstd"
|
||||
val archiveExt = if (isZstd) ".zst" else ".gz"
|
||||
val archiveRaw = File(appDir, "${packageName}_data.tar$archiveExt")
|
||||
|
||||
Log.d(TAG, "backupUserData: $packageName checking dirs")
|
||||
|
||||
val rawPkg = packageName
|
||||
val dataPaths = listOf("/data/data/$rawPkg", "/data/user_de/$userId/$rawPkg")
|
||||
|
||||
// 1. Try direct paths (app's mount namespace)
|
||||
val dirs = dataPaths.filter { RootShell.exec("test -d $it").isSuccess }.toMutableList()
|
||||
var result: RootShell.ShellResult? = null
|
||||
var archiveCreated = false
|
||||
|
||||
if (dirs.isNotEmpty()) {
|
||||
Log.d(TAG, "backupUserData: $packageName test -d found dirs=$dirs")
|
||||
result = runTar(dirs, outputFile, isZstd)
|
||||
archiveCreated = result?.isSuccess == true
|
||||
} else {
|
||||
// 2. Try tar directly on direct paths (may fail in isolated namespace)
|
||||
Log.d(TAG, "backupUserData: $packageName test -d all failed, trying tar directly")
|
||||
result = runTar(dataPaths, outputFile, isZstd)
|
||||
archiveCreated = result?.isSuccess == true || (archiveRaw.exists() && archiveRaw.length() > 0)
|
||||
}
|
||||
|
||||
// 3. If still failed, try via /proc/1/root (global mount namespace)
|
||||
// Use cd to avoid tar packing the /proc/1/root/ prefix into the archive.
|
||||
if (!archiveCreated) {
|
||||
Log.w(TAG, "backupUserData: $packageName direct access failed, trying via /proc/1/root (global namespace)")
|
||||
val globalRelPaths = dataPaths.map { it.removePrefix("/") } // e.g. "data/data/tv.danmaku.bili"
|
||||
val excludeArgs = "--exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup'"
|
||||
val globalCmd = if (isZstd) {
|
||||
"cd /proc/1/root && tar $excludeArgs -cf - ${globalRelPaths.joinToString(" ")} 2>/dev/null | zstd -T0 -o '$outputFile.zst'"
|
||||
} else {
|
||||
"cd /proc/1/root && tar $excludeArgs -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ")} 2>/dev/null"
|
||||
}
|
||||
result = RootShell.exec(globalCmd)
|
||||
archiveCreated = result?.isSuccess == true || (archiveRaw.exists() && archiveRaw.length() > 0)
|
||||
}
|
||||
|
||||
// 4. Last resort: try su -Z (Magisk SELinux context switch) — on Magisk 30+,
|
||||
// the app's su context (untrusted_app) cannot see other apps' /data/data/.
|
||||
// Switching to u:r:magisk:s0 lifts the SELinux restriction.
|
||||
if (!archiveCreated) {
|
||||
Log.w(TAG, "backupUserData: $packageName /proc/1/root failed, trying su -Z magisk context")
|
||||
val excludeArgs = "--exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup'"
|
||||
val dirList = dataPaths.joinToString(" ")
|
||||
val rawOut = appDir.absolutePath + "/" + packageName + "_data.tar"
|
||||
val innerCmd = if (isZstd) {
|
||||
"tar $excludeArgs -cf - $dirList 2>/dev/null | zstd -T0 -o '${rawOut}.zst'"
|
||||
} else {
|
||||
"tar $excludeArgs -czf '${rawOut}.gz' $dirList 2>/dev/null"
|
||||
}
|
||||
result = RootShell.exec("su -Z u:r:magisk:s0 -c \"$innerCmd\" 2>/dev/null")
|
||||
archiveCreated = result?.isSuccess == true || (archiveRaw.exists() && archiveRaw.length() > 0)
|
||||
}
|
||||
|
||||
if (!archiveCreated) {
|
||||
Log.w(TAG, "backupUserData: $packageName all methods failed — no data dirs to backup (or inaccessible)")
|
||||
return true
|
||||
}
|
||||
// Verify integrity
|
||||
val verifyOk = if (isZstd) {
|
||||
RootShell.exec("zstd -t '$outputFile.zst' 2>/dev/null").isSuccess
|
||||
} else {
|
||||
RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess
|
||||
}
|
||||
if (!verifyOk) {
|
||||
Log.e(TAG, "backupUserData: $packageName integrity check FAILED")
|
||||
}
|
||||
return verifyOk
|
||||
}
|
||||
|
||||
/** Run tar for given paths, building the appropriate zstd/gzip command. */
|
||||
private suspend fun runTar(
|
||||
dirs: List<String>,
|
||||
outputFile: String,
|
||||
isZstd: Boolean
|
||||
): RootShell.ShellResult {
|
||||
val excludeArgs = "--exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup'"
|
||||
val result = when (compression) {
|
||||
"zstd" -> {
|
||||
val dirList = dirs.joinToString(" ")
|
||||
RootShell.exec(
|
||||
"tar $excludeArgs -cf - $dirList 2>/dev/null | zstd -T0 -o '$outputFile.zst'"
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val dirList = dirs.joinToString(" ")
|
||||
RootShell.exec(
|
||||
"tar $excludeArgs -czf '$outputFile.gz' $dirList 2>/dev/null"
|
||||
)
|
||||
}
|
||||
val dirList = dirs.joinToString(" ")
|
||||
return if (isZstd) {
|
||||
RootShell.exec("tar $excludeArgs -cf - $dirList 2>/dev/null | zstd -T0 -o '$outputFile.zst'")
|
||||
} else {
|
||||
RootShell.exec("tar $excludeArgs -czf '$outputFile.gz' $dirList 2>/dev/null")
|
||||
}
|
||||
if (!result.isSuccess) {
|
||||
Log.e(TAG, "Failed to backup data for $packageName: exit=${result.exitCode} err=${result.error}")
|
||||
return false
|
||||
}
|
||||
// Verify the compressed archive integrity
|
||||
val verificationOk = when (compression) {
|
||||
"zstd" -> RootShell.exec("zstd -t '$outputFile.zst' 2>/dev/null").isSuccess
|
||||
else -> RootShell.exec("gzip -t '$outputFile.gz' 2>/dev/null").isSuccess
|
||||
}
|
||||
if (!verificationOk) {
|
||||
Log.e(TAG, "Data archive integrity check FAILED for $packageName")
|
||||
}
|
||||
return verificationOk
|
||||
}
|
||||
|
||||
private suspend fun backupObb(packageName: String, appDir: File, compression: String): Boolean {
|
||||
|
||||
@@ -80,11 +80,17 @@ class RemoteSyncManager {
|
||||
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
|
||||
|
||||
@@ -148,16 +148,18 @@ interface RemoteTransport {
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
// Download remote files that are new or have different size
|
||||
var downloaded = 0
|
||||
var transferred = 0
|
||||
var skipped = 0
|
||||
val syncTotal = remoteFiles.size
|
||||
for ((relPath, info) in remoteByPath) {
|
||||
downloaded++
|
||||
onProgress(TransferProgress("download", downloaded, syncTotal, relPath))
|
||||
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)")
|
||||
@@ -187,7 +189,7 @@ interface RemoteTransport {
|
||||
Log.i(TAG, "syncFromRemote deleting stale local: $relPath")
|
||||
try { localFile.delete() } catch (_: Exception) {}
|
||||
}
|
||||
onProgress(TransferProgress("complete", syncTotal, syncTotal))
|
||||
onProgress(TransferProgress("complete", transferred, syncTotal, "已传输: $transferred 跳过: $skipped"))
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("syncFromRemote failed: ${e.message}", e))
|
||||
@@ -233,15 +235,17 @@ interface RemoteTransport {
|
||||
|
||||
// Upload new or changed local files
|
||||
var uploaded = 0
|
||||
var uploadSkipped = 0
|
||||
val syncTotal = localFiles.size
|
||||
for ((relPath, localFile) in localFiles) {
|
||||
uploaded++
|
||||
onProgress(TransferProgress("upload", uploaded, syncTotal, relPath))
|
||||
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)") {
|
||||
@@ -268,7 +272,7 @@ interface RemoteTransport {
|
||||
Log.i(TAG, "syncToRemote deleting stale: $relPath")
|
||||
transport.delete("$remoteDir/$relPath")
|
||||
}
|
||||
onProgress(TransferProgress("complete", localFiles.size, localFiles.size))
|
||||
onProgress(TransferProgress("complete", uploaded, syncTotal, "已传输: $uploaded 跳过: $uploadSkipped"))
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("syncToRemote failed: ${e.message}", e))
|
||||
|
||||
@@ -78,7 +78,7 @@ class ResticBackup(
|
||||
if (!line.startsWith("{")) continue
|
||||
try {
|
||||
val summary = resticJson.decodeFromString<ResticWrapper.BackupSummary>(line)
|
||||
if (summary.snapshotId.isNotEmpty()) return Result.success(summary)
|
||||
if (summary.messageType == "summary" && summary.snapshotId.isNotEmpty()) return Result.success(summary)
|
||||
} catch (_: Exception) { /* keep looking */ }
|
||||
}
|
||||
return Result.failure(Exception("No summary found in restic output"))
|
||||
|
||||
@@ -18,10 +18,8 @@ object ResticBinary {
|
||||
synchronized(this) {
|
||||
if (cacheInit) return cachedBinaryPath
|
||||
val nativeLibDir = context.applicationInfo.nativeLibraryDir
|
||||
Log.d(TAG, "nativeLibraryDir = $nativeLibDir")
|
||||
|
||||
val path = File(nativeLibDir, BINARY_NAME)
|
||||
Log.d(TAG, "restic: exists=${path.isFile} len=${path.length()} canExec=${path.canExecute()}")
|
||||
Log.d(TAG, "nativeLibraryDir=$nativeLibDir exists=${path.isFile} len=${path.length()} canExec=${path.canExecute()}")
|
||||
|
||||
cachedBinaryPath = if (path.isFile) {
|
||||
Log.i(TAG, "librestic.so ready at ${path.absolutePath} (${path.length()} bytes)")
|
||||
|
||||
@@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -38,7 +39,7 @@ class ResticCommandRunner {
|
||||
val cmdArgs = buildCommandArgs(args)
|
||||
Log.i(TAG, "runRestic cmd=${cmdArgs.joinToString(" ")}")
|
||||
Log.d(TAG, "runRestic REPOSITORY=${env["RESTIC_REPOSITORY"]}")
|
||||
|
||||
env["TMPDIR"]?.let { File(it).mkdirs() }
|
||||
return try {
|
||||
val pb = ProcessBuilder(cmdArgs)
|
||||
pb.environment().putAll(env)
|
||||
@@ -84,6 +85,7 @@ class ResticCommandRunner {
|
||||
val cmdArgs = buildCommandArgs(args)
|
||||
Log.i(TAG, "runResticStreaming cmd=${cmdArgs.joinToString(" ")}")
|
||||
Log.d(TAG, "runResticStreaming REPOSITORY=${env["RESTIC_REPOSITORY"]}")
|
||||
env["TMPDIR"]?.let { File(it).mkdirs() }
|
||||
|
||||
var process: Process? = null
|
||||
try {
|
||||
|
||||
@@ -29,6 +29,9 @@ class ResticEnvResolver {
|
||||
val cacheDir = tempRepoDir.substringBeforeLast("/") + "/restic_cache"
|
||||
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"
|
||||
env["TMPDIR"] = tmpDir
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
@@ -102,19 +102,20 @@ object ResticWrapper {
|
||||
|
||||
@Serializable
|
||||
data class BackupSummary(
|
||||
@SerialName("message_type") val messageType: String = "",
|
||||
@SerialName("snapshot_id") val snapshotId: String,
|
||||
@SerialName("files_new") val filesNew: Int,
|
||||
@SerialName("files_changed") val filesChanged: Int,
|
||||
@SerialName("files_unmodified") val filesUnmodified: Int,
|
||||
@SerialName("dirs_new") val dirsNew: Int,
|
||||
@SerialName("dirs_changed") val dirsChanged: Int,
|
||||
@SerialName("dirs_unmodified") val dirsUnmodified: Int,
|
||||
@SerialName("data_blobs") val dataBlobs: Int,
|
||||
@SerialName("tree_blobs") val treeBlobs: Int,
|
||||
@SerialName("data_added") val dataAdded: Long,
|
||||
@SerialName("total_files_processed") val totalFilesProcessed: Int,
|
||||
@SerialName("total_bytes_processed") val totalBytesProcessed: Long,
|
||||
@SerialName("total_duration") val totalDuration: Double
|
||||
@SerialName("files_new") val filesNew: Int = 0,
|
||||
@SerialName("files_changed") val filesChanged: Int = 0,
|
||||
@SerialName("files_unmodified") val filesUnmodified: Int = 0,
|
||||
@SerialName("dirs_new") val dirsNew: Int = 0,
|
||||
@SerialName("dirs_changed") val dirsChanged: Int = 0,
|
||||
@SerialName("dirs_unmodified") val dirsUnmodified: Int = 0,
|
||||
@SerialName("data_blobs") val dataBlobs: Int = 0,
|
||||
@SerialName("tree_blobs") val treeBlobs: Int = 0,
|
||||
@SerialName("data_added") val dataAdded: Long = 0,
|
||||
@SerialName("total_files_processed") val totalFilesProcessed: Int = 0,
|
||||
@SerialName("total_bytes_processed") val totalBytesProcessed: Long = 0,
|
||||
@SerialName("total_duration") val totalDuration: Double = 0.0
|
||||
)
|
||||
|
||||
suspend fun backup(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -18,6 +19,8 @@ import kotlinx.serialization.Serializable
|
||||
*/
|
||||
object RestoreOperation {
|
||||
|
||||
private const val TAG = "RestoreOperation"
|
||||
|
||||
@Serializable
|
||||
data class RestoreProgress(
|
||||
val current: Int,
|
||||
@@ -161,26 +164,37 @@ object RestoreOperation {
|
||||
}
|
||||
|
||||
private suspend fun restoreData(appDir: File) {
|
||||
|
||||
// Find data archive
|
||||
val dataFiles = appDir.listFiles()
|
||||
?.filter { it.name.contains("_data.tar") }
|
||||
?: return
|
||||
|
||||
val files = appDir.listFiles()
|
||||
if (files.isNullOrEmpty()) {
|
||||
Log.w(TAG, "restoreData: appDir empty or null: ${appDir.absolutePath}")
|
||||
return
|
||||
}
|
||||
val dataFiles = files.filter { it.name.contains("_data.tar") }
|
||||
if (dataFiles.isEmpty()) {
|
||||
Log.w(TAG, "restoreData: no _data.tar in ${appDir.name}, found: ${files.map { it.name }}")
|
||||
return
|
||||
}
|
||||
for (archive in dataFiles) {
|
||||
val archivePath = archive.absolutePath.shellEscape()
|
||||
// Verify archive doesn't contain path traversal before extracting
|
||||
if (!isArchiveSafe(archive)) continue
|
||||
when {
|
||||
archive.name.endsWith(".zst") -> {
|
||||
RootShell.exec("zstd -d -c '$archivePath' | tar -xf - -C / 2>/dev/null")
|
||||
}
|
||||
archive.name.endsWith(".gz") -> {
|
||||
RootShell.exec("tar -xzf '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
archive.name.endsWith(".tar") -> {
|
||||
RootShell.exec("tar -xf '$archivePath' -C / 2>/dev/null")
|
||||
}
|
||||
Log.d(TAG, "restoreData: found archive ${archive.name}")
|
||||
if (!isArchiveSafe(archive)) {
|
||||
Log.w(TAG, "restoreData: archive NOT SAFE, skipping: ${archive.name}")
|
||||
continue
|
||||
}
|
||||
val cmd = when {
|
||||
archive.name.endsWith(".zst") ->
|
||||
"zstd -d -c '$archivePath' | tar -xf - -C / 2>/dev/null"
|
||||
archive.name.endsWith(".gz") ->
|
||||
"tar -xzf '$archivePath' -C / 2>/dev/null"
|
||||
archive.name.endsWith(".tar") ->
|
||||
"tar -xf '$archivePath' -C / 2>/dev/null"
|
||||
else -> { Log.w(TAG, "restoreData: unknown archive type ${archive.name}"); continue }
|
||||
}
|
||||
val result = RootShell.exec(cmd)
|
||||
if (result.isSuccess) {
|
||||
Log.i(TAG, "restoreData: extracted ${archive.name}")
|
||||
} else {
|
||||
Log.e(TAG, "restoreData: FAILED ${archive.name}: exit=${result.exitCode} err=${result.error}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,15 +270,17 @@ object RestoreOperation {
|
||||
val permFile = File(appDir, "permissions.txt")
|
||||
if (!permFile.exists()) return
|
||||
|
||||
val perms = permFile.readLines()
|
||||
.filter { it.contains("granted=true") }
|
||||
.mapNotNull { line ->
|
||||
// Extract permission name from dumpsys output
|
||||
// Format: "permission.name: granted=true" or similar
|
||||
line.substringBefore(":")
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() && it.contains(".") }
|
||||
}
|
||||
// dumpsys 输出格式: "android.permission.XXX: granted=true" 或 "permission.XXX: granted=true"
|
||||
// 各 Android 版本输出有差异,try-catch 兜底避免单权限失败中断全部
|
||||
val perms = try {
|
||||
permFile.readLines()
|
||||
.filter { it.contains("granted=true") }
|
||||
.mapNotNull { line ->
|
||||
line.substringBefore(":")
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() && it.contains(".") }
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
for (perm in perms) {
|
||||
|
||||
@@ -5,12 +5,15 @@ import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Backup and restore WiFi configuration.
|
||||
* Mirrors backup_script WiFi backup/restore logic.
|
||||
*/
|
||||
object WifiManager {
|
||||
private const val TAG = "WifiManager"
|
||||
|
||||
|
||||
// Possible WiFi config paths on different Android versions
|
||||
private val WIFI_PATHS = listOf(
|
||||
@@ -57,21 +60,27 @@ object WifiManager {
|
||||
// Try the most common path
|
||||
val fallback = "/data/misc/apexdata/com.android.wifi/WifiConfigStore.xml"
|
||||
val parent = File(fallback).parentFile?.absolutePath?.shellEscape() ?: return@withContext false
|
||||
RootShell.exec("mkdir -p '$parent'")
|
||||
val mkdirResult = RootShell.exec("mkdir -p '$parent'")
|
||||
if (!mkdirResult.isSuccess) return@withContext false
|
||||
val result = RootShell.exec("cp '$backupPath' '$fallback'")
|
||||
if (!result.isSuccess) return@withContext false
|
||||
RootShell.exec("chown system:wifi '$fallback'")
|
||||
RootShell.exec("chmod 0660 '$fallback'")
|
||||
val chownResult = RootShell.exec("chown system:wifi '$fallback'")
|
||||
if (!chownResult.isSuccess) Log.w(TAG, "chown failed: ${chownResult.error}")
|
||||
val chmodResult = RootShell.exec("chmod 0660 '$fallback'")
|
||||
if (!chmodResult.isSuccess) Log.w(TAG, "chmod failed: ${chmodResult.error}")
|
||||
} else {
|
||||
val result = RootShell.exec("cp '$backupPath' '$wifiTarget'")
|
||||
if (!result.isSuccess) return@withContext false
|
||||
RootShell.exec("chown system:wifi '$wifiTarget'")
|
||||
RootShell.exec("chmod 0660 '$wifiTarget'")
|
||||
val chownResult = RootShell.exec("chown system:wifi '$wifiTarget'")
|
||||
if (!chownResult.isSuccess) Log.w(TAG, "chown failed: ${chownResult.error}")
|
||||
val chmodResult = RootShell.exec("chmod 0660 '$wifiTarget'")
|
||||
if (!chmodResult.isSuccess) Log.w(TAG, "chmod failed: ${chmodResult.error}")
|
||||
}
|
||||
|
||||
// WiFi backup only takes effect after reboot, but we can try reloading
|
||||
RootShell.exec("svc wifi disable 2>/dev/null")
|
||||
RootShell.exec("svc wifi enable 2>/dev/null")
|
||||
// These are best-effort since reloading WiFi only takes full effect on reboot
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
package com.example.androidbackupgui.root
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.*
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.io.InputStream
|
||||
import android.util.Log
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
/**
|
||||
* Escape a string for safe use inside single-quoted shell strings.
|
||||
@@ -15,23 +14,16 @@ import android.util.Log
|
||||
fun String.shellEscape(): String = this.replace("'", "'\\''")
|
||||
|
||||
/**
|
||||
* Persistent root shell session via `su`.
|
||||
* Manages a single su process and executes commands sequentially.
|
||||
* Thread-safe via Mutex — all session state is guarded by the mutex.
|
||||
* Root shell access via libsu.
|
||||
* Shell.cmd internally manages su sessions, compatible with Magisk/KernelSU/APatch.
|
||||
* All shell operations are thread-safe through coroutine dispatchers.
|
||||
*/
|
||||
object RootShell {
|
||||
|
||||
private var process: Process? = null
|
||||
private var writer: OutputStreamWriter? = null
|
||||
private var reader: BufferedReader? = null
|
||||
private var errReader: BufferedReader? = null
|
||||
|
||||
private const val TAG = "RootShell"
|
||||
/** Default command timeout in milliseconds. */
|
||||
private const val COMMAND_TIMEOUT_MS = 120_000L
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
/** Result of a shell command execution. */
|
||||
data class ShellResult(
|
||||
val output: String,
|
||||
@@ -41,134 +33,40 @@ object RootShell {
|
||||
val isSuccess get() = exitCode == 0
|
||||
}
|
||||
|
||||
/** Quick process-alive check. Caller MUST hold the mutex. */
|
||||
private fun isAliveUnsafe(): Boolean {
|
||||
val p = process ?: return false
|
||||
return try { p.exitValue(); false } catch (_: IllegalThreadStateException) { true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Open (or re-open) the su session and verify root access.
|
||||
* Caller MUST hold the mutex.
|
||||
* Trigger root shell pre-initialization.
|
||||
* Returns true if root is available.
|
||||
* Note: Shell.cmd() also auto-initializes on first use, so this is optional.
|
||||
*/
|
||||
private fun ensureSessionUnsafe(): Boolean {
|
||||
if (isAliveUnsafe()) return true
|
||||
return try {
|
||||
val p = Runtime.getRuntime().exec(arrayOf("su"))
|
||||
writer = OutputStreamWriter(p.outputStream)
|
||||
reader = BufferedReader(InputStreamReader(p.inputStream))
|
||||
errReader = BufferedReader(InputStreamReader(p.errorStream))
|
||||
process = p
|
||||
// Drain stderr in background to prevent pipe-buffer deadlock
|
||||
Thread({
|
||||
try { while (errReader?.readLine() != null) {} } catch (_: Exception) {}
|
||||
}, "su-stderr-drain").apply { isDaemon = true; start() }
|
||||
// Inline verification — cannot call exec() which would deadlock on mutex
|
||||
val sentinel = "ROOT_OK_${System.nanoTime()}"
|
||||
writer?.write("echo $sentinel\n"); writer?.flush()
|
||||
var line: String?
|
||||
while (reader?.readLine().also { line = it } != null) {
|
||||
if (line!!.contains(sentinel)) return true
|
||||
}
|
||||
false
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure a root shell is open. Returns true if root is available. */
|
||||
suspend fun ensureSession(): Boolean = mutex.withLock {
|
||||
ensureSessionUnsafe()
|
||||
}
|
||||
|
||||
/** Cleanup all session state. Caller MUST hold the mutex. */
|
||||
private fun closeUnsafe() {
|
||||
try { writer?.close() } catch (_: Exception) {}
|
||||
try { reader?.close() } catch (_: Exception) {}
|
||||
try { errReader?.close() } catch (_: Exception) {}
|
||||
try { process?.destroy() } catch (_: Exception) {}
|
||||
process = null
|
||||
writer = null
|
||||
reader = null
|
||||
errReader = null
|
||||
}
|
||||
|
||||
/** Close the root shell session. */
|
||||
suspend fun close() = mutex.withLock {
|
||||
closeUnsafe()
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and return the output.
|
||||
* Uses a sentinel delimiter to identify end of output.
|
||||
* Timeout is enforced via structured coroutine cancellation:
|
||||
* `withTimeout(timeoutMs)` cancels the coroutine, interrupting the
|
||||
* blocking readLine() on Dispatchers.IO. If the process cannot be
|
||||
* interrupted, closeUnsafe() destroys it in the catch handler.
|
||||
*/
|
||||
suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult = mutex.withLock {
|
||||
if (!isAliveUnsafe() && !ensureSessionUnsafe()) {
|
||||
return@exec ShellResult("", "No root access", -1)
|
||||
}
|
||||
|
||||
val sentinel = "EXIT_${System.nanoTime()}"
|
||||
writer?.write("$command; echo $sentinel \$?\n")
|
||||
writer?.flush()
|
||||
|
||||
suspend fun ensureSession(): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
withTimeout(timeoutMs) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val output = StringBuilder()
|
||||
var line: String?
|
||||
while (reader?.readLine().also { line = it } != null) {
|
||||
val l = line!!
|
||||
if (l.startsWith(sentinel)) {
|
||||
val code = l.removePrefix("$sentinel ").trim().toIntOrNull() ?: -1
|
||||
return@withContext ShellResult(output.toString().trimEnd(), "", code)
|
||||
}
|
||||
output.appendLine(l)
|
||||
}
|
||||
// Process destroyed or readLine returned null naturally
|
||||
ShellResult(output.toString().trimEnd(), "", -1)
|
||||
}
|
||||
}
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
Log.w(TAG, "exec timeout (${timeoutMs}ms) destroying process: $command")
|
||||
closeUnsafe()
|
||||
ShellResult("", "Command timed out after ${timeoutMs}ms", -1)
|
||||
}
|
||||
Shell.getShell().isRoot
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command via `su` and return the stdout as an InputStream
|
||||
* for binary-safe streaming. Caller MUST close the stream and call
|
||||
* waitForStreamResult() or destroy the returned process.
|
||||
* Execute a shell command and return the result.
|
||||
* libsu internally runs via `su`, compatible with Magisk/KernelSU/APatch.
|
||||
* Commands are passed verbatim to `su -c`, so pipes and redirects work normally.
|
||||
* Timeout is enforced via structured coroutine cancellation.
|
||||
*/
|
||||
class StreamProcess(
|
||||
val process: Process,
|
||||
val inputStream: InputStream,
|
||||
private val command: String
|
||||
) {
|
||||
fun waitFor(): Int {
|
||||
try { process.waitFor() } catch (_: Exception) {}
|
||||
return process.exitValue()
|
||||
suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val result = withTimeout(timeoutMs) {
|
||||
Shell.cmd(command).exec()
|
||||
}
|
||||
ShellResult(
|
||||
output = result.out.joinToString("\n"),
|
||||
error = result.err.joinToString("\n"),
|
||||
exitCode = result.code,
|
||||
)
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
Log.w(TAG, "exec timeout (${timeoutMs}ms): $command")
|
||||
ShellResult("", "Command timed out after ${timeoutMs}ms", -1)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "exec failed: $command", e)
|
||||
ShellResult("", e.message ?: "Unknown error", -1)
|
||||
}
|
||||
}
|
||||
fun destroy() {
|
||||
try { process.destroy() } catch (_: Exception) {}
|
||||
try { inputStream.close() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
fun execBinary(command: String): StreamProcess? {
|
||||
return try {
|
||||
val p = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
|
||||
// Drain stderr to prevent pipe deadlock
|
||||
Thread({
|
||||
try { p.errorStream.use { it.readBytes() } } catch (_: Exception) {}
|
||||
}, "su-binary-stderr").apply { isDaemon = true }.start()
|
||||
StreamProcess(p, p.inputStream, command)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,13 @@ class BackupFragment : Fragment() {
|
||||
binding.backupButton.setOnClickListener { startBackup() }
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Re-read config so changes from ConfigFragment take effect immediately
|
||||
val configFile = File(requireContext().filesDir, "backup_settings.conf")
|
||||
config = BackupConfig.fromFile(configFile)
|
||||
}
|
||||
|
||||
private fun scanApps() {
|
||||
binding.backupButton.isEnabled = false
|
||||
setRunning(true)
|
||||
@@ -104,6 +111,7 @@ class BackupFragment : Fragment() {
|
||||
|
||||
// If restic is enabled, snapshot the backup to a restic repository
|
||||
var resticSummary: ResticWrapper.BackupSummary? = null
|
||||
var resticError: String? = null
|
||||
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
|
||||
val binaryPath = ResticBinary.prepare(requireContext())
|
||||
if (binaryPath != null) {
|
||||
@@ -161,6 +169,7 @@ class BackupFragment : Fragment() {
|
||||
resticResult.fold(
|
||||
onSuccess = { resticSummary = it },
|
||||
onFailure = { e ->
|
||||
resticError = e.message
|
||||
binding.statusText.text = "restic 快照失败: ${e.message}"
|
||||
}
|
||||
)
|
||||
@@ -178,6 +187,10 @@ class BackupFragment : Fragment() {
|
||||
appendLine("ID: ${resticSummary!!.snapshotId.take(8)}…")
|
||||
appendLine("新增: ${resticSummary!!.dataAdded / 1024 / 1024} MB")
|
||||
appendLine("文件: ${resticSummary!!.totalFilesProcessed}")
|
||||
} else if (resticError != null) {
|
||||
appendLine()
|
||||
appendLine("── Restic 错误 ──")
|
||||
appendLine(resticError!!)
|
||||
}
|
||||
}
|
||||
setRunning(false)
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.example.androidbackupgui.backup.AppInfo
|
||||
@@ -21,6 +22,8 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class RestoreFragment : Fragment() {
|
||||
|
||||
@@ -65,6 +68,21 @@ class RestoreFragment : Fragment() {
|
||||
binding.restoreButton.setOnClickListener { startRestore() }
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Re-read config so changes from ConfigFragment take effect immediately
|
||||
val configFile = File(requireContext().filesDir, "backup_settings.conf")
|
||||
val config = BackupConfig.fromFile(configFile)
|
||||
resticConfig = if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) config else null
|
||||
val binaryPath = ResticBinary.prepare(requireContext())
|
||||
if (binaryPath != null && resticConfig != null) {
|
||||
ResticWrapper.binaryPath = binaryPath
|
||||
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
|
||||
ResticWrapper.backendDomain = config.resticBackendDomain
|
||||
binding.selectResticButton.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectBackupDir() {
|
||||
val defaultDir = File(requireContext().filesDir.absolutePath)
|
||||
val backupDirs = defaultDir.listFiles()
|
||||
@@ -132,9 +150,20 @@ class RestoreFragment : Fragment() {
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 多快照时让用户选择,单个快照自动选
|
||||
val chosenSnapshot = if (snapshots.size == 1) {
|
||||
snapshots.first()
|
||||
} else {
|
||||
pickSnapshot(snapshots) ?: run {
|
||||
binding.statusText.text = "已取消选择"
|
||||
setRunning(false)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to restic source
|
||||
backupDir = null
|
||||
selectedSnapshot = snapshots.first()
|
||||
selectedSnapshot = chosenSnapshot
|
||||
val backupPath = selectedSnapshot!!.paths.firstOrNull() ?: run {
|
||||
binding.statusText.text = "快照中找不到备份路径"
|
||||
setRunning(false)
|
||||
@@ -157,6 +186,7 @@ class RestoreFragment : Fragment() {
|
||||
|
||||
binding.backupDirText.text = "restic: ${selectedSnapshot!!.time.take(19)} (${snapshots.size} 个快照可用)"
|
||||
selectedPackages.clear()
|
||||
|
||||
selectedPackages.addAll(packages)
|
||||
|
||||
// Resolve app labels for display
|
||||
@@ -169,6 +199,17 @@ class RestoreFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 多快照时弹出选择对话框。返回用户选择的快照,取消时返回 null。 */
|
||||
private suspend fun pickSnapshot(snapshots: List<ResticWrapper.ResticSnapshot>): ResticWrapper.ResticSnapshot? =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val names = snapshots.map { "${it.time.take(19)} (${it.id.take(8)})" }
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setTitle("选择快照")
|
||||
.setItems(names.toTypedArray()) { _, i -> cont.resume(snapshots[i]) }
|
||||
.setOnCancelListener { cont.resume(null) }
|
||||
.show()
|
||||
}
|
||||
|
||||
/** Read a single file from a restic snapshot using `restic dump`. */
|
||||
private suspend fun readResticFile(
|
||||
config: BackupConfig,
|
||||
@@ -208,66 +249,66 @@ class RestoreFragment : Fragment() {
|
||||
val snapshot = selectedSnapshot!!
|
||||
val config = resticConfig!!
|
||||
val backupPath = snapshot.paths.firstOrNull() ?: return@launch
|
||||
|
||||
val staging = File(requireContext().cacheDir, "restic_restore_${snapshot.shortId}")
|
||||
staging.mkdirs()
|
||||
|
||||
binding.statusText.text = "正在从 restic 快照恢复到暂存目录…"
|
||||
val restoreResult = ResticWrapper.restore(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
snapshotId = snapshot.id,
|
||||
targetPath = staging.absolutePath,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
onSyncProgress = { progress: RemoteTransport.TransferProgress ->
|
||||
withContext(Dispatchers.Main) {
|
||||
when (progress.phase) {
|
||||
"list", "download", "upload", "delete_stale" ->
|
||||
binding.statusText.text = "同步中: ${progress.current}/${progress.total} 个文件"
|
||||
try {
|
||||
binding.statusText.text = "正在从 restic 快照恢复到暂存目录…"
|
||||
val restoreResult = ResticWrapper.restore(
|
||||
repoPath = config.resticRepo,
|
||||
password = config.resticPassword,
|
||||
snapshotId = snapshot.id,
|
||||
targetPath = staging.absolutePath,
|
||||
backend = config.resticBackend,
|
||||
backendUrl = config.resticBackendUrl,
|
||||
backendUser = config.resticBackendUser,
|
||||
backendPass = config.resticBackendPass,
|
||||
backendShare = config.resticBackendShare,
|
||||
onSyncProgress = { progress: RemoteTransport.TransferProgress ->
|
||||
withContext(Dispatchers.Main) {
|
||||
when (progress.phase) {
|
||||
"list", "download", "upload", "delete_stale" ->
|
||||
binding.statusText.text = "同步中: ${progress.current}/${progress.total} 个文件"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onByteSyncProgress = { progress ->
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
|
||||
binding.progressBar.progress = progress.bytesTransferred.toInt()
|
||||
binding.statusText.text = "同步中: ${progress.currentFile}\n" +
|
||||
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}"
|
||||
}
|
||||
},
|
||||
onProgress = { msg -> binding.statusText.text = msg }
|
||||
)
|
||||
},
|
||||
onByteSyncProgress = { progress ->
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.progressBar.max = progress.totalBytes.toInt().coerceAtLeast(1)
|
||||
binding.progressBar.progress = progress.bytesTransferred.toInt()
|
||||
binding.statusText.text = "同步中: ${progress.currentFile}\n" +
|
||||
"${formatSize(progress.bytesTransferred)} / ${formatSize(progress.totalBytes)}"
|
||||
}
|
||||
},
|
||||
onProgress = { msg -> binding.statusText.text = msg }
|
||||
)
|
||||
|
||||
if (restoreResult.isFailure) {
|
||||
binding.statusText.text = "restic 恢复失败: ${restoreResult.exceptionOrNull()?.message}"
|
||||
setRunning(false)
|
||||
binding.selectDirButton.isEnabled = true
|
||||
return@launch
|
||||
}
|
||||
|
||||
// The restored backup directory: <staging>/<original_absolute_path>
|
||||
val restoredBackupDir = File(staging, backupPath.removePrefix("/"))
|
||||
binding.statusText.text = "正在从恢复的备份安装应用…"
|
||||
|
||||
val r = RestoreOperation.restoreApps(
|
||||
backupDir = restoredBackupDir,
|
||||
filterPkgs = selectedPackages,
|
||||
onProgress = { progress ->
|
||||
val label = appInfos.find { it.packageName == progress.packageName }?.label
|
||||
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
|
||||
binding.statusText.text =
|
||||
"[${progress.current}/${progress.total}] $name: ${progress.message}"
|
||||
if (restoreResult.isFailure) {
|
||||
binding.statusText.text = "restic 恢复失败: ${restoreResult.exceptionOrNull()?.message}"
|
||||
setRunning(false)
|
||||
binding.selectDirButton.isEnabled = true
|
||||
return@launch
|
||||
}
|
||||
)
|
||||
// Also restore WiFi if backup exists
|
||||
WifiManager.restore(restoredBackupDir)
|
||||
// Cleanup staging
|
||||
try { staging.deleteRecursively() } catch (_: Exception) {}
|
||||
r
|
||||
|
||||
// The restored backup directory: <staging>/<original_absolute_path>
|
||||
val restoredBackupDir = File(staging, backupPath.removePrefix("/"))
|
||||
binding.statusText.text = "正在从恢复的备份安装应用…"
|
||||
|
||||
val r = RestoreOperation.restoreApps(
|
||||
backupDir = restoredBackupDir,
|
||||
filterPkgs = selectedPackages,
|
||||
onProgress = { progress ->
|
||||
val label = appInfos.find { it.packageName == progress.packageName }?.label
|
||||
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
|
||||
binding.statusText.text =
|
||||
"[${progress.current}/${progress.total}] $name: ${progress.message}"
|
||||
}
|
||||
)
|
||||
// Also restore WiFi if backup exists
|
||||
WifiManager.restore(restoredBackupDir)
|
||||
r
|
||||
} finally {
|
||||
try { staging.deleteRecursively() } catch (_: Exception) {}
|
||||
}
|
||||
} else {
|
||||
// Local restore
|
||||
val dir = backupDir ?: return@launch
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# 用户偏好
|
||||
- 交流语言:所有回复必须使用中文。这是核心要求,新对话需自动加载。
|
||||
- 项目:Android Backup GUI(Kotlin 应用)
|
||||
- 工作目录:~/github_projects/android-backup-gui
|
||||
|
||||
# 项目背景
|
||||
Android Backup GUI(Kotlin app with native root execution, SMB/WebDAV remote storage, WiFi backup)和 CodeGraph global setup(MCP server for code intelligence, auto-init prompt in CLAUDE.md)。
|
||||
|
||||
# 技术要点
|
||||
- root shell persistence
|
||||
- SMB troubleshooting (ECONNREFUSED)
|
||||
- 构建命令: ./gradlew assembleDebug
|
||||
Reference in New Issue
Block a user