21 Commits

Author SHA1 Message Date
sakuradairong
0bde3b0a75 fix: try su -Z u:r:magisk:s0 for SELinux context switch
Magisk 30+ adds -Z flag to switch SELinux context. On this device,
the app's su runs as u:r:untrusted_app:s0 which cannot access other
apps' /data/data/. Switching to u:r:magisk:s0 lifts this restriction.
2026-06-01 21:57:11 +08:00
sakuradairong
d2ea9f532f fix: add su -mm fallback for Magisk isolated mount namespace
On some Magisk/Zygisk configurations, the app's su session stays
in an isolated mount namespace AND SELinux context that blocks even
/proc/1/root access. Add su -mm (Magisk mount namespace master) as
a fourth fallback attempt — it forkes a new su session in the global
mount namespace where all /data/data/ directories are accessible.
2026-06-01 21:45:39 +08:00
sakuradairong
ac0fd8b063 fix: access app data via /proc/1/root for isolated mount namespace
On some Magisk/Zygisk configurations, su from an app process stays
in the app's mount namespace where /data/data/<other-pkg> is not
visible (exit 1, 'No such file or directory').

Fix: three-tier fallback in backupUserData:
1. test -d on direct paths (standard)
2. tar directly on direct paths (covers test -d edge cases)
3. cd /proc/1/root && tar (crosses mount namespace boundary to init's
   global namespace, where all /data/data/ directories are visible)
2026-06-01 21:35:45 +08:00
sakuradairong
fde6d05b83 fix: bypass test -d quoting issue, use raw pkg paths with tar fallback
libsu's shell wrapper mishandles single-quoted path segments from
shellEscape(), causing test -d /data/data/'tv.danmaku.bili' to return
exit 1 even though the directory exists. Fix:

- Use raw (unescaped) package name for path checking commands
- Fallback: if test -d fails, try tar directly (which works even when
  test -d doesn't) to detect and back up existing data directories
- Restructure to avoid double tar execution in fallback path
2026-06-01 21:29:16 +08:00
sakuradairong
2ff096ee8a debug: add ls-d fallback when test -d fails in backupUserData 2026-06-01 21:21:35 +08:00
sakuradairong
eae7f4b369 debug: log backupUserData test -d exit codes to diagnose missing data archive 2026-06-01 21:18:19 +08:00
sakuradairong
420d960ce1 fix: restoreData logging catches empty filter result
The ?: operator only triggers on null, not empty list.
Replace with explicit null check + filter + empty check,
logging actual filenames when no _data.tar is found.
2026-06-01 21:02:10 +08:00
sakuradairong
88e81e956c debug: add logging to restoreData/restoreObb for silent failure diagnosis
restoreData and restoreObb currently log nothing on failure and return
Unit, making it impossible to diagnose why data archives aren't being
extracted. Add Log.d/w/e calls for every step: file discovery, archive
safety check, extraction command, and result.
2026-06-01 20:46:55 +08:00
sakuradairong
c12f7a9c81 chore: cleanup restic_tmp directory after sync 2026-06-01 20:27:59 +08:00
sakuradairong
a43638698c fix: set TMPDIR for restic pack files on Android
Android has no /tmp directory, causing restic backup to fail with:
  Fatal: unable to save snapshot: open /tmp/restic-temp-pack-...: no such file or directory

- Add TMPDIR env var pointing to a writable app cache subdirectory
- Ensure the tmp directory is created before restic runs
2026-06-01 20:27:30 +08:00
sakuradairong
12fe29f841 fix: preserve restic error in final status, revert binary copy
- Restic error message was overwritten by '备份完成!' before user
  could see it — now included in final status as '── Restic 错误 ──'
- Revert copy-to-filesDir: SELinux blocks exec from app private dir
- Use native library directory directly (Android extracts with exec perms)
2026-06-01 20:23:37 +08:00
sakuradairong
b7addcca6b fix: revert ResticBinary to native lib dir, use direct exec path
The copy-to-filesDir approach fails on Android 10+ because SELinux
blocks execution from app private data directories even with
setExecutable(true). Revert to using the native library directory
directly — Android PackageManager extracts native libs with proper
execution permissions there.

Also: the copy change also reset ResticBinary.cacheInit=false, meaning
the cached "Permission denied" path would be re-picked up. Full revert
ensures the next prepare() call uses the correct executable path.
2026-06-01 20:20:51 +08:00
sakuradairong
98d4029fd4 fix: reload config onResume so Restic settings take effect without restart
BackupFragment and RestoreFragment read config only in onViewCreated(),
so config changes saved in ConfigFragment (e.g. enabling Restic) were
picked up only after app restart (when fragments are recreated). Add
onResume() to both fragments to re-read config from file.
2026-06-01 20:14:10 +08:00
sakuradairong
07366f744f fix: copy restic binary to writable dir before execution
Native library directory may be mounted noexec on Android 10+,
preventing ProcessBuilder from executing librestic.so directly.
Copy to app's filesDir/restic_bin/librestic and set executable
permission before use.
2026-06-01 20:12:33 +08:00
sakuradairong
88e18f4c57 chore: gitignore restic test repo containing encryption keys 2026-06-01 17:44:53 +08:00
sakuradairong
d8992c3931 chore: gitignore memory files from agent harness
memory:/ paths resolve to literal files in the project directory.
Add gitignore pattern to prevent accidental commits.
2026-06-01 17:39:00 +08:00
sakuradairong
c7e9d8b5f1 chore: remove memory files from git tracking (internal harness paths) 2026-06-01 17:38:07 +08:00
sakuradairong
3e1f8a5937 fix: conditionally sign release APK when keystore exists
release.keystore is gitignored (regenerate locally), so CI checks out
without it. Make signingConfig and signing reference conditional on
file existence, allowing CI to produce an unsigned APK while local
builds still get a signed release.
2026-06-01 17:38:02 +08:00
sakuradairong
80b84f6cff refactor: migrate to libsu root shell with unified exec interface
Replace custom persistent su process management (Mutex-guarded
process, InputStream/OutputStreamReader) with libsu's Shell.cmd.

RootShell.kt changes:
- Remove: process lifecycle, mutex, stdin/stdout/stderr management,
  execBinary/execStreaming, sentinel health-check pattern
- Add: libsu Shell.getShell()/Shell.cmd() with structured timeout,
  single public exec() method returning ShellResult
- Keep: shellEscape() extension, ensureSession(), ShellResult data class

Caller changes:
- WifiManager.kt: add return-value checks for mkdir, chown, chmod;
  mark best-effort wifi reload as intentionally unchecked

Supporting:
- app/build.gradle: add libsu 6.0.0 dependency
- AGENTS.md / CLAUDE.md: update GitNexus index stats
2026-06-01 17:27:26 +08:00
sakuradairong
0b017e853b fix: restore review fixes — snapshot dialog, staging finally, permission try-catch 2026-06-01 16:53:43 +08:00
sakuradairong
2351cce99e fix: incremental backup progress — add message_type check, skip count in sync progress 2026-06-01 16:45:16 +08:00
18 changed files with 346 additions and 303 deletions

6
.gitignore vendored
View File

@@ -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/

View File

@@ -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.

View File

@@ -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.

View File

@@ -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'
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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))

View File

@@ -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"))

View File

@@ -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)")

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -1,12 +0,0 @@
# 用户偏好
- 交流语言:所有回复必须使用中文。这是核心要求,新对话需自动加载。
- 项目Android Backup GUIKotlin 应用)
- 工作目录:~/github_projects/android-backup-gui
# 项目背景
Android Backup GUIKotlin app with native root execution, SMB/WebDAV remote storage, WiFi backup和 CodeGraph global setupMCP server for code intelligence, auto-init prompt in CLAUDE.md
# 技术要点
- root shell persistence
- SMB troubleshooting (ECONNREFUSED)
- 构建命令: ./gradlew assembleDebug