30 Commits
v1.1 ... v1.0.0

Author SHA1 Message Date
sakuradairong
6cdad04905 feat: improve backup quality from Android-DataBackup analysis
Phase 1 — Core Backup Quality:
- Add archive tar structure validation after compression
- Exclude cache/.ota/lib/code_cache/no_backup from data backup
- Add keystore detection with user warning
- Enhance SSAID parsing (XML attribute) and restore via settings put secure

Phase 2 — App Data Model + Multi-user:
- Add DataSizes data class, enrich AppInfo with userId/keystore/iconPath
- Add icon backup from snapshot cache or APK
- Multi-user enumeration and per-user scanning (pm list users)
- User profile selector in backup/restore UI

Phase 3 — UI + Service:
- Add sort (A-Z, size), filter (system apps toggle), select all/deselect all
- New BackupService foreground service to prevent process death
- AndroidManifest: FOREGROUND_SERVICE + POST_NOTIFICATIONS permissions

Phase 4 — Polish:
- Add LogUtil with file rotation (7-day retention, filesDir/logs/)
- Wire file logging into backup/restore entry/exit points
2026-06-02 00:54:30 +08:00
sakuradairong
5cbd21577b fix: correct TransferProgress/ByteProgress field names in snapshot progress 2026-06-01 23:29:46 +08:00
sakuradairong
1bae01de72 perf: add sync progress to restic snapshot loading 2026-06-01 23:14:17 +08:00
sakuradairong
e710c36ee2 fix: BinaryResolver per-binary cache (was sharing incorrect cross-binary state) 2026-06-01 23:03:31 +08:00
sakuradairong
c1bbef4eef feat: bundle zstd and tar binaries in jniLibs for reliable data backup
Download statically compiled ARM64 zstd (1.3MB) and tar (1.1MB)
binaries from YAWAsau/backup_script, placed in jniLibs as
libzstd_bin.so and libtar_bin.so. BinaryResolver extracts them to
filesDir/bin/ with execute permissions.

backupUserData auto-detects:
1. Bundled zstd → use absolute path
2. System zstd → use PATH lookup
3. No zstd → fall back to gzip (tar -czf)

This eliminates the 'command not found' (exit=127) issue when nsenter
+ FLAG_MOUNT_MASTER switches to a namespace with different PATH.
2026-06-01 23:01:17 +08:00
sakuradairong
4c4542e059 fix: auto-detect zstd availability, fall back to gzip when missing
With nsenter + FLAG_MOUNT_MASTER, test -d now correctly finds
/data/data/<pkg>. But zstd binary is absent on this device (previous
backups never reached tar|zstd because dirs were always empty).
Auto-detect zstd before creating archive; fall back to tar -czf (gzip)
when unavailable.
2026-06-01 22:55:08 +08:00
sakuradairong
ef78ab8bec debug: log tar stderr in each fallback step to identify actual error 2026-06-01 22:49:42 +08:00
sakuradairong
a38a483c70 fix: configure libsu with FLAG_MOUNT_MASTER and nsenter global namespace initializer
DataBackup (XayahSuSuSu) approach: configure the default libsu builder
with:
- Shell.FLAG_MOUNT_MASTER (requests su --mount-master from Magisk)
- GlobalNamespaceInitializer: runs 'nsenter --mount=/proc/1/ns/mnt sh'
  at shell init time, switching to init's global mount namespace
  where ALL /data/data/ directories are visible.

This replaces the previous 4-tier fallback hacks with a proper
shell-level fix at the libsu configuration layer.
2026-06-01 22:42:51 +08:00
sakuradairong
d0bfef41c8 fix: replace su -Z with magiskpolicy SELinux relax in backupUserData fallback 2026-06-01 22:33:16 +08:00
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
28 changed files with 1082 additions and 442 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

@@ -5,6 +5,9 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
@@ -22,6 +25,11 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".backup.BackupService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>

View File

@@ -12,6 +12,7 @@ import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.example.androidbackupgui.databinding.ActivityMainBinding
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.backup.LogUtil
import com.example.androidbackupgui.ui.BackupFragment
import com.example.androidbackupgui.ui.ConfigFragment
import com.example.androidbackupgui.ui.RestoreFragment
@@ -34,9 +35,15 @@ class MainActivity : AppCompatActivity() {
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// Configure libsu with global mount namespace support
RootShell.configure()
// Request root access on startup
lifecycleScope.launch(Dispatchers.IO) {
RootShell.ensureSession()
// Initialize file-based logging
LogUtil.init(filesDir)
}
// Edge-to-edge: pad toolbar below status bar

View File

@@ -7,6 +7,15 @@ import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
@Serializable
data class DataSizes(
val apkBytes: Long = 0,
val userBytes: Long = 0,
val userDeBytes: Long = 0,
val dataBytes: Long = 0,
val obbBytes: Long = 0,
val mediaBytes: Long = 0,
)
@Serializable
data class AppInfo(
@@ -16,27 +25,32 @@ data class AppInfo(
val apkPaths: List<String> = emptyList(),
val hasObb: Boolean = false,
val isRunning: Boolean = false,
val backupSize: Long = 0 // estimated from last backup
val backupSize: Long = 0, // estimated from last backup
// Enhanced fields (multi-user, keystore, icon)
val userId: Int = 0,
val hasKeystore: Boolean = false,
val iconPath: String? = null,
val dataSizes: DataSizes = DataSizes(),
)
object AppScanner {
/** Scan all third-party installed packages. */
suspend fun scanThirdParty(context: Context): List<AppInfo> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list packages -3")
suspend fun scanThirdParty(context: Context, userId: Int = 0): List<AppInfo> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list packages -3 --user $userId")
if (!result.isSuccess) return@withContext emptyList()
val packages = result.output.lines()
.filter { it.startsWith("package:") }
.map { it.removePrefix("package:").trim() }
.filter { it.isNotEmpty() }
.map { AppInfo(packageName = it) }
.map { AppInfo(packageName = it, userId = userId) }
resolveLabels(context, packages)
}
/** Scan all system packages. */
suspend fun scanSystem(context: Context, config: BackupConfig): List<AppInfo> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list packages -s")
suspend fun scanSystem(context: Context, config: BackupConfig, userId: Int = 0): List<AppInfo> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list packages -s --user $userId")
if (!result.isSuccess) return@withContext emptyList()
val systemWhitelist = config.system.toSet()
@@ -48,14 +62,12 @@ object AppScanner {
.map { it.removePrefix("package:").trim() }
.filter { it.isNotEmpty() }
.filter { pkg ->
// Allow if in system whitelist or data whitelist
pkg in systemWhitelist || pkg in dataWhitelist
}
.filter { pkg ->
// Exclude if in blacklist (when blacklistMode=1, full ignore)
if (config.blacklistMode == 1) pkg !in blacklist else true
}
.map { AppInfo(packageName = it, isSystem = true) }
.map { AppInfo(packageName = it, isSystem = true, userId = userId) }
resolveLabels(context, packages)
}
@@ -112,7 +124,69 @@ object AppScanner {
val result = RootShell.exec("pidof '${packageName.shellEscape()}'")
result.output.isNotBlank()
}
/** Check if an app has keystore entries (critical — keystore keys can be lost on backup). */
suspend fun hasKeystore(packageName: String): Boolean = withContext(Dispatchers.IO) {
// Resolve the app's UID first
val uidResult = RootShell.exec("dumpsys package '$packageName' | grep 'userId=' | head -1")
val uid = uidResult.output
.substringAfter("userId=", "")
.substringBefore(" ")
.substringBefore(",")
.trim()
.toIntOrNull() ?: return@withContext false
// keystore_cli_v2 list as app UID — more than 1 line means has keystore entries
val ksResult = RootShell.exec("su $uid -c 'keystore_cli_v2 list' 2>/dev/null")
ksResult.output.lines().count { it.isNotBlank() } > 1
}
/** Enumerate all user profiles on the device for multi-user support. */
suspend fun enumerateUsers(): List<Pair<Int, String>> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list users")
if (!result.isSuccess) return@withContext listOf(0 to "Owner")
result.output.lines()
.filter { it.contains("UserInfo") }
.mapNotNull { line ->
val id = line.substringBefore(":").trim().toIntOrNull()
val name = line.substringAfter(":").substringBefore(":").trim()
if (id != null) id to name else null
}
}
/** Extract and save an app's icon to the given directory. */
suspend fun extractIcon(packageName: String, destDir: java.io.File, userId: Int = 0): String? = withContext(Dispatchers.IO) {
// Try snapshot cache first
val snapshotDir = "/data/system_ce/$userId/snapshots/$packageName"
val snapshotResult = RootShell.exec("ls '$snapshotDir/' 2>/dev/null | head -1")
if (snapshotResult.isSuccess && snapshotResult.output.isNotBlank()) {
val iconName = snapshotResult.output.trim()
val iconFile = java.io.File(destDir, "app_icon.png")
val copyResult = RootShell.exec("cp '${snapshotDir}/${iconName.shellEscape()}' '${iconFile.absolutePath.shellEscape()}' 2>/dev/null")
if (copyResult.isSuccess && iconFile.exists()) {
return@withContext iconFile.absolutePath
}
}
// Fallback: extract from APK using aapt
val apkPaths = getApkPaths(packageName)
if (apkPaths.isNotEmpty()) {
val primaryApk = apkPaths.first()
val badgeResult = RootShell.exec("aapt d badging '$primaryApk' 2>/dev/null | grep '^application:.*icon=' | head -1")
if (badgeResult.isSuccess) {
val iconPath = badgeResult.output
.substringAfter("icon='")
.substringBefore("'")
.takeIf { it.isNotBlank() }
if (iconPath != null) {
// The icon path is relative inside the APK, extract using aapt
val iconFile = java.io.File(destDir, "app_icon.png")
RootShell.exec("aapt d raw '$primaryApk' '$iconPath' > '${iconFile.absolutePath.shellEscape()}' 2>/dev/null")
if (iconFile.exists()) {
return@withContext iconFile.absolutePath
}
}
}
}
null
}
/** Apply appList.txt-style filters. Lines starting with # are ignored, ! means apk-only. */
fun parseAppList(content: String): List<Pair<String, Boolean>> {
return content.lines()

View File

@@ -51,6 +51,7 @@ object BackupOperation {
* @param onProgress callback for UI updates
*/
suspend fun backupApps(
context: android.content.Context,
apps: List<AppInfo>,
config: BackupConfig,
outputDir: File,
@@ -63,6 +64,7 @@ object BackupOperation {
// Create backup structure
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
backupRoot.mkdirs()
LogUtil.i(TAG, "backupApps: starting backup of ${apps.size} apps to ${backupRoot.absolutePath}")
// Write app list
val appListFile = File(backupRoot, "appList.txt")
@@ -102,10 +104,16 @@ object BackupOperation {
return@withPermit
}
// 1.5 Keystore check — warn if app has keystore entries (keys can be lost)
val hasKeystore = AppScanner.hasKeystore(app.packageName)
if (hasKeystore) {
emit(BackupProgress(index + 1, apps.size, app.packageName, "data", "⚠ 此应用包含密钥库条目,备份后密钥可能会丢失"))
}
// 2. Backup user data (if configured)
if (config.backupMode == 1 && config.backupUserData == 1) {
emit(BackupProgress(index + 1, apps.size, app.packageName, "data", "正在备份数据…"))
if (!backupUserData(app.packageName, appDir, userId, config.compressionMethod)) {
if (!backupUserData(context, app.packageName, appDir, userId, config.compressionMethod)) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "数据备份失败"))
return@withPermit
@@ -129,6 +137,12 @@ object BackupOperation {
emit(BackupProgress(index + 1, apps.size, app.packageName, "ssaid", "正在备份 SSAID…"))
backupSsaid(app.packageName, appDir, userId)
// 4.5 Backup app icon
val iconPath = AppScanner.extractIcon(app.packageName, appDir, app.userId)
if (iconPath != null) {
Log.d(TAG, "backupApps: saved icon for ${app.packageName} -> $iconPath")
}
// 5. Backup runtime permissions
backupPermissions(app.packageName, appDir)
@@ -136,16 +150,22 @@ object BackupOperation {
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "完成"))
}
}
}
}
}
val elapsed = System.currentTimeMillis() - startTime
RootShell.exec("chmod -R 0755 '${backupRoot.absolutePath}'")
val successCount = successAtomic.get()
val failCount = failAtomic.get()
val skippedCount = skippedAtomic.get()
LogUtil.i(TAG, "backupApps: completed — success=$successCount fail=$failCount skipped=$skippedCount elapsed=${elapsed}ms")
BackupResult(
successCount = successAtomic.get(),
failCount = failAtomic.get(),
skippedCount = skippedAtomic.get(),
successCount = successCount,
failCount = failCount,
skippedCount = skippedCount,
outputDir = backupRoot.absolutePath,
elapsedMs = elapsed
)
@@ -153,58 +173,125 @@ object BackupOperation {
private suspend fun backupUserData(
context: android.content.Context,
packageName: String,
appDir: File,
userId: String,
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 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"
)
// Resolve bundled binary paths (fall back to system PATH if not bundled)
val bundledTar = BinaryResolver.tarPath(context)
val tarCmd = bundledTar ?: "tar"
var isZstd = compression == "zstd"
val bundledZstd = if (isZstd) BinaryResolver.zstdPath(context) else null
val zstdCmd = bundledZstd ?: "zstd"
if (isZstd && bundledZstd == null) {
val zstdCheck = RootShell.exec("$zstdCmd --version 2>/dev/null")
if (!zstdCheck.isSuccess) {
Log.w(TAG, "backupUserData: zstd not available, falling back to gzip")
isZstd = false
}
}
if (!result.isSuccess) {
Log.e(TAG, "Failed to backup data for $packageName: exit=${result.exitCode} err=${result.error}")
val archiveExt = if (isZstd) ".zst" else ".gz"
val archiveRaw = File(appDir, "${packageName}_data.tar$archiveExt")
Log.d(TAG, "backupUserData: $packageName checking dirs (tar=$tarCmd zstd=$zstdCmd)")
val rawPkg = packageName
val dataPaths = listOf("/data/data/$rawPkg", "/data/user_de/$userId/$rawPkg")
val dataExcludes = listOf(".ota", "cache", "lib", "code_cache", "no_backup")
// 1. Try direct paths after nsenter namespace switch
var archiveCreated = false
var result: RootShell.ShellResult? = null
val dirs = dataPaths.filter { RootShell.exec("test -d $it").isSuccess }.toMutableList()
if (dirs.isNotEmpty()) {
Log.d(TAG, "backupUserData: $packageName test -d found dirs=$dirs")
result = runTar(dirs, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
Log.d(TAG, "backupUserData: $packageName step1 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
} else {
Log.d(TAG, "backupUserData: $packageName test -d all failed, trying tar directly")
result = runTar(dataPaths, outputFile, isZstd, tarCmd, zstdCmd, excludes = dataExcludes)
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
Log.d(TAG, "backupUserData: $packageName step2 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
}
// 3. Fallback via /proc/1/root (global mount namespace)
if (!archiveCreated) {
Log.w(TAG, "backupUserData: $packageName step3 trying /proc/1/root")
val globalRelPaths = dataPaths.map { it.removePrefix("/") }
val globalCmd = if (isZstd) {
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -cf - ${globalRelPaths.joinToString(" ")} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'"
} else {
"cd /proc/1/root && $tarCmd --exclude='.ota' --exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup' -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ")} 2>/dev/null"
}
result = RootShell.exec(globalCmd)
archiveCreated = archiveCreated || (archiveRaw.exists() && archiveRaw.length() > 0)
Log.d(TAG, "backupUserData: $packageName step3 exit=${result?.exitCode} err='${result?.error?.take(100)}'")
}
if (!archiveCreated) {
Log.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)")
return true
}
// Verify compression integrity
val verifyOk = if (isZstd) {
RootShell.exec("$zstdCmd -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 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
// Validate tar archive structure (Android-DataBackup Tar.test() pattern)
val tarValidateOk = if (isZstd) {
RootShell.exec("$zstdCmd -d -c '$outputFile.zst' 2>/dev/null | tar -tf - > /dev/null 2>&1").isSuccess
} else {
RootShell.exec("tar -tf '$outputFile.gz' > /dev/null 2>&1").isSuccess
}
if (!verificationOk) {
Log.e(TAG, "Data archive integrity check FAILED for $packageName")
if (!tarValidateOk) {
Log.e(TAG, "backupUserData: $packageName tar archive structure validation FAILED")
return false
}
return verificationOk
return true
}
/** Run tar for given paths, building the appropriate zstd/gzip command. */
private suspend fun runTar(
dirs: List<String>,
outputFile: String,
isZstd: Boolean,
tarCmd: String = "tar",
zstdCmd: String = "zstd",
excludes: List<String> = emptyList()
): RootShell.ShellResult {
val excludeArgs = if (excludes.isNotEmpty()) {
excludes.joinToString(" ") { "--exclude='$it'" }
} else ""
return if (isZstd) {
RootShell.exec("$tarCmd -cf - $excludeArgs ${dirs.joinToString(" ")} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'")
} else {
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ")} 2>/dev/null")
}
}
private suspend fun backupObb(packageName: String, appDir: File, compression: String): Boolean {
val obbDir = "/storage/emulated/0/Android/obb/${packageName.shellEscape()}"
val escapedAppDir = appDir.absolutePath.shellEscape()
val escapedPkg = packageName.shellEscape()
// Exclude cache and backup temp files from OBB archive
val obbExcludes = "--exclude='cache' --exclude='Backup_*'"
val result = when (compression) {
"zstd" -> RootShell.exec("tar -cf - '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'")
else -> RootShell.exec("tar -czf '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
"zstd" -> RootShell.exec("tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'")
else -> RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
}
if (!result.isSuccess) {
Log.e(TAG, "Failed to backup OBB for $packageName: exit=${result.exitCode} err=${result.error}")
@@ -216,14 +303,30 @@ object BackupOperation {
if (!verificationOk) {
Log.e(TAG, "OBB archive integrity check FAILED for $packageName")
}
return verificationOk
// Validate OBB tar structure
val tarListCmd = if (compression == "zstd") "zstd -d -c '$archive' 2>/dev/null | tar -tf - > /dev/null 2>&1" else "tar -tf '$archive' > /dev/null 2>&1"
val tarOk = RootShell.exec(tarListCmd).isSuccess
if (!tarOk) {
Log.e(TAG, "OBB tar structure validation FAILED for $packageName")
}
return verificationOk && tarOk
}
private suspend fun backupSsaid(packageName: String, appDir: File, userId: String) {
val ssaidFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
val result = RootShell.exec("grep '${packageName.shellEscape()}' '$ssaidFile' 2>/dev/null")
if (result.output.isNotBlank()) {
File(appDir, "ssaid.txt").writeText(result.output)
// Parse XML value attribute for this package's SSAID entry
val result = RootShell.exec("cat '$ssaidFile' 2>/dev/null")
if (!result.isSuccess || result.output.isBlank()) return
val ssaidLine = result.output.lines().firstOrNull { line ->
line.contains("packageName=\"$packageName\"") || line.contains("packageName='$packageName'")
}
val value = ssaidLine
?.substringAfter("value=\"")
?.substringBefore("\"")
?.takeIf { it.isNotBlank() }
if (value != null) {
File(appDir, "ssaid.txt").writeText(value)
Log.d(TAG, "backupSsaid: backed up SSAID for $packageName = $value")
}
}

View File

@@ -0,0 +1,73 @@
package com.example.androidbackupgui.backup
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
/**
* Foreground service to keep the process alive during long backup/restore operations.
* Prevents Android from killing the app during extended operations.
*/
class BackupService : Service() {
companion object {
const val CHANNEL_ID = "backup_service_channel"
const val NOTIFICATION_ID = 1001
const val ACTION_START_BACKUP = "com.example.androidbackupgui.action.START_BACKUP"
const val ACTION_STOP_BACKUP = "com.example.androidbackupgui.action.STOP_BACKUP"
const val EXTRA_STATUS_TEXT = "status_text"
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START_BACKUP -> {
val statusText = intent.getStringExtra(EXTRA_STATUS_TEXT) ?: "正在备份…"
val notification = createNotification(statusText)
startForeground(NOTIFICATION_ID, notification)
}
ACTION_STOP_BACKUP -> {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"备份服务",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "后台备份任务持续运行通知"
setShowBadge(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
private fun createNotification(text: String): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Android Backup")
.setContentText(text)
.setSmallIcon(android.R.drawable.ic_menu_upload)
.setOngoing(true)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
}

View File

@@ -0,0 +1,49 @@
package com.example.androidbackupgui.backup
import android.content.Context
import android.util.Log
import java.io.File
/**
* Resolves paths to binaries bundled in jniLibs.
* Android's PackageManager extracts lib*.so from jniLibs to nativeLibraryDir.
* We copy them to app-private dir (writable, executable) for ProcessBuilder use.
*/
object BinaryResolver {
private const val TAG = "BinaryResolver"
private val cacheTar = ResolveCache()
private val cacheZstd = ResolveCache()
private class ResolveCache {
var initialized = false
var path: String? = null
}
fun tarPath(context: Context): String? = resolve(context, "libtar_bin.so", "tar_bin", cacheTar)
fun zstdPath(context: Context): String? = resolve(context, "libzstd_bin.so", "zstd_bin", cacheZstd)
private fun resolve(context: Context, libName: String, destName: String, cache: ResolveCache): String? {
if (cache.initialized) return cache.path
val nativeLibDir = context.applicationInfo.nativeLibraryDir
val source = File(nativeLibDir, libName)
if (!source.isFile) {
Log.e(TAG, "$libName NOT FOUND at ${source.absolutePath}")
cache.initialized = true
cache.path = null
return null
}
val dest = File(context.filesDir, "bin/$destName")
if (!dest.exists() || dest.length() != source.length() || !dest.canExecute()) {
dest.parentFile?.mkdirs()
if (dest.exists()) dest.delete()
source.inputStream().use { src -> dest.outputStream().use { out -> src.copyTo(out) } }
dest.setExecutable(true)
}
val result = dest.absolutePath
Log.i(TAG, "ready: $libName -> $result (${dest.length()} bytes) canExec=${dest.canExecute()}")
cache.path = result
cache.initialized = true
return result
}
}

View File

@@ -0,0 +1,85 @@
package com.example.androidbackupgui.backup
import android.util.Log
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
/**
* File-based logger with rotation support.
* Writes logs to [baseDir]/logs/YYYY-MM-dd.log, keeping up to [maxDays] days.
* Also dispatches to Android Logcat for real-time visibility.
*/
object LogUtil {
private const val TAG = "LogUtil"
private const val MAX_DAYS = 7
private var baseDir: File? = null
private val executor = Executors.newSingleThreadExecutor()
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
private val timestampFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
fun init(baseDir: File) {
this.baseDir = baseDir
executor.execute { rotateLogs() }
}
fun i(tag: String, message: String) {
Log.i(tag, message)
writeLog("I", tag, message)
}
fun w(tag: String, message: String) {
Log.w(tag, message)
writeLog("W", tag, message)
}
fun e(tag: String, message: String) {
Log.e(tag, message)
writeLog("E", tag, message)
}
private fun writeLog(level: String, tag: String, message: String) {
val dir = baseDir ?: return
executor.execute {
try {
val today = dateFormat.format(Date())
val logFile = File(File(dir, "logs"), "$today.log")
logFile.parentFile?.mkdirs()
val timestamp = timestampFormat.format(Date())
val line = "$timestamp $level/$tag: $message\n"
logFile.appendText(line)
} catch (_: Exception) {
// Silently fail — logging should never crash the app
}
}
}
private fun rotateLogs() {
val dir = baseDir ?: return
val logDir = File(dir, "logs")
if (!logDir.exists()) return
val cutoff = System.currentTimeMillis() - MAX_DAYS * 24L * 60 * 60 * 1000
logDir.listFiles()
?.filter { it.name.endsWith(".log") }
?.forEach { file ->
if (file.lastModified() < cutoff) {
file.delete()
}
}
}
/** Get all log files sorted by name (date ascending). */
fun getLogFiles(): List<File> {
val dir = baseDir ?: return emptyList()
val logDir = File(dir, "logs")
return logDir.listFiles()
?.filter { it.name.endsWith(".log") }
?.sortedBy { it.name }
?: emptyList()
}
}

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,
@@ -66,6 +69,7 @@ object RestoreOperation {
} else {
allPackages
}
LogUtil.i(TAG, "restoreApps: starting restore of ${packages.size} packages from ${backupDir.absolutePath}")
val successAtomic = AtomicInteger(0)
val failAtomic = AtomicInteger(0)
@@ -122,7 +126,10 @@ object RestoreOperation {
}
val elapsed = System.currentTimeMillis() - startTime
RestoreResult(successAtomic.get(), failAtomic.get(), elapsed)
val successCount = successAtomic.get()
val failCount = failAtomic.get()
LogUtil.i(TAG, "restoreApps: completed — success=$successCount fail=$failCount elapsed=${elapsed}ms")
RestoreResult(successCount, failCount, elapsed)
}
private suspend fun installApk(appDir: File): Boolean {
@@ -161,26 +168,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}")
}
}
}
@@ -237,34 +255,53 @@ object RestoreOperation {
val ssaidFile = File(appDir, "ssaid.txt")
if (!ssaidFile.exists()) return
val ssaidLine = ssaidFile.readText().trim()
if (ssaidLine.isBlank()) return
val ssaidValue = ssaidFile.readText().trim()
if (ssaidValue.isBlank()) return
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
val pkgEsc = packageName.shellEscape()
val ssaidEsc = ssaidLine.shellEscape()
// Resolve the app's UID
val uidResult = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep 'userId=' | head -1")
val uid = uidResult.output
.substringAfter("userId=", "")
.substringBefore(" ")
.substringBefore(",")
.trim()
.toIntOrNull()
// Remove existing entry for this package, insert new one before </settings>
RootShell.exec(
"grep -v '${pkgEsc}' '$targetFile' > '$targetFile.tmp' && " +
"sed -i '\$ i ${ssaidEsc}' '$targetFile.tmp' && " +
"mv '$targetFile.tmp' '$targetFile'"
)
if (uid != null) {
// Use settings put secure to set SSAID (more reliable than XML manipulation)
val result = RootShell.exec("settings put secure ssaid_$uid '$ssaidValue'")
if (result.isSuccess) {
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName (uid=$uid)")
} else {
Log.w(TAG, "restoreSsaid: failed to set SSAID for $packageName: ${result.error}")
}
} else {
Log.w(TAG, "restoreSsaid: could not resolve UID for $packageName, falling back to XML edit")
// Fallback: edit settings_ssaid.xml directly
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
RootShell.exec(
"grep -v '${packageName.shellEscape()}' '$targetFile' > '$targetFile.tmp' && " +
"sed -i '\$ i ${ssaidValue.shellEscape()}' '$targetFile.tmp' && " +
"mv '$targetFile.tmp' '$targetFile'"
)
}
}
private suspend fun restorePermissions(packageName: String, appDir: File) {
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,55 @@ 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.
* libsu shell initializer: enter global mount namespace via nsenter.
* Preserves the original PATH so that tar/zstd (from Termux etc.) remain accessible.
* Ref: DataBackup (XayahSuSuSu) uses the same nsenter pattern.
*/
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
private class GlobalNamespaceInitializer : Shell.Initializer() {
override fun onInit(context: android.content.Context, shell: Shell): Boolean {
shell.newJob()
.add("nsenter --mount=/proc/1/ns/mnt sh")
.add("set -o pipefail")
.exec()
return true
}
}
/** Ensure a root shell is open. Returns true if root is available. */
suspend fun ensureSession(): Boolean = mutex.withLock {
ensureSessionUnsafe()
/** Call once at app startup to configure libsu. */
fun configure() {
Shell.enableVerboseLogging = true
Shell.setDefaultBuilder(
Shell.Builder.create()
.setFlags(Shell.FLAG_MOUNT_MASTER)
.setInitializers(GlobalNamespaceInitializer::class.java)
.setTimeout(30)
)
}
/** 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)
Shell.getShell().isRoot
} catch (_: Exception) { false }
}
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)
}
} catch (e: TimeoutCancellationException) {
Log.w(TAG, "exec timeout (${timeoutMs}ms) destroying process: $command")
closeUnsafe()
ShellResult("", "Command timed out after ${timeoutMs}ms", -1)
}
}
/**
* 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.
*/
class StreamProcess(
val process: Process,
val inputStream: InputStream,
private val command: String
) {
fun waitFor(): Int {
try { process.waitFor() } catch (_: Exception) {}
return process.exitValue()
}
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

@@ -1,9 +1,12 @@
package com.example.androidbackupgui.ui
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
@@ -11,6 +14,7 @@ import com.example.androidbackupgui.backup.AppInfo
import com.example.androidbackupgui.backup.AppScanner
import com.example.androidbackupgui.backup.BackupConfig
import com.example.androidbackupgui.backup.BackupOperation
import com.example.androidbackupgui.backup.BackupService
import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.ResticWrapper
import com.example.androidbackupgui.backup.WifiManager
@@ -28,7 +32,14 @@ class BackupFragment : Fragment() {
private val binding get() = _binding!!
private var apps: List<AppInfo> = emptyList()
private var selectedApps = mutableSetOf<String>()
private var sortedApps: List<AppInfo> = emptyList()
private lateinit var config: BackupConfig
private var selectedUserId: Int = 0
private var userList: List<Pair<Int, String>> = listOf(0 to "Owner")
private var sortMode: SortMode = SortMode.NAME_ASC
private var showSystemApps: Boolean = false
private enum class SortMode { NAME_ASC, SIZE_DESC }
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
@@ -47,6 +58,53 @@ class BackupFragment : Fragment() {
binding.scanButton.setOnClickListener { scanApps() }
binding.backupButton.setOnClickListener { startBackup() }
// Sort/filter controls
binding.sortAZButton.setOnClickListener {
sortMode = SortMode.NAME_ASC
applySortFilter()
}
binding.sortSizeButton.setOnClickListener {
sortMode = SortMode.SIZE_DESC
applySortFilter()
}
binding.selectAllButton.setOnClickListener {
selectedApps.addAll(apps.map { it.packageName })
applySortFilter()
}
binding.deselectAllButton.setOnClickListener {
selectedApps.clear()
applySortFilter()
}
binding.showSystemSwitch.setOnCheckedChangeListener { _, checked ->
showSystemApps = checked
applySortFilter()
}
// Load user profiles and setup dropdown
loadUsers()
}
private fun loadUsers() {
viewLifecycleOwner.lifecycleScope.launch {
userList = AppScanner.enumerateUsers()
val names = userList.map { (id, name) -> "$name (ID: $id)" }
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.userSelector.adapter = adapter
binding.userSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedUserId = userList.getOrNull(position)?.first ?: 0
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
}
override fun onResume() {
super.onResume()
val configFile = File(requireContext().filesDir, "backup_settings.conf")
config = BackupConfig.fromFile(configFile)
}
private fun scanApps() {
@@ -56,9 +114,9 @@ class BackupFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch {
val ctx = requireContext()
val thirdParty = AppScanner.scanThirdParty(ctx)
val system = AppScanner.scanSystem(ctx, config)
apps = thirdParty + system
val thirdParty = AppScanner.scanThirdParty(ctx, userId = selectedUserId)
val system = AppScanner.scanSystem(ctx, config, userId = selectedUserId)
apps = if (showSystemApps) thirdParty + system else thirdParty
selectedApps.clear()
selectedApps.addAll(apps.map { it.packageName })
@@ -66,131 +124,163 @@ class BackupFragment : Fragment() {
binding.backupButton.isEnabled = apps.isNotEmpty()
setRunning(false)
setupAppList()
applySortFilter()
}
}
private fun applySortFilter() {
var filtered = if (showSystemApps) apps else apps.filter { !it.isSystem }
filtered = when (sortMode) {
SortMode.NAME_ASC -> filtered.sortedBy { it.label.lowercase(Locale.US) }
SortMode.SIZE_DESC -> filtered.sortedByDescending { it.backupSize }
}
sortedApps = filtered
setupAppList()
binding.statusText.text = "已选择 ${selectedApps.size}/${sortedApps.size} 个应用"
}
private fun setupAppList() {
binding.appList.adapter = PackageListAdapter(apps, selectedApps) { pkg, checked ->
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}/${apps.size} 个应用"
binding.statusText.text = "已选择 ${selectedApps.size}/${displayApps.size} 个应用"
}
}
private fun startBackup() {
val toBackup = apps.filter { it.packageName in selectedApps }
if (toBackup.isEmpty()) return
setRunning(true)
binding.backupButton.isEnabled = false
binding.scanButton.isEnabled = false
// Start foreground service to keep process alive
val serviceIntent = Intent(requireContext(), BackupService::class.java)
serviceIntent.action = BackupService.ACTION_START_BACKUP
serviceIntent.putExtra(BackupService.EXTRA_STATUS_TEXT, "正在备份 ${toBackup.size} 个应用…")
try {
requireContext().startForegroundService(serviceIntent)
} catch (_: Exception) {}
viewLifecycleOwner.lifecycleScope.launch {
val outputDir = File(config.outputPath.ifEmpty {
requireContext().filesDir.absolutePath
})
WifiManager.backup(outputDir)
val result = BackupOperation.backupApps(
apps = toBackup,
config = config,
outputDir = outputDir,
onProgress = { progress ->
val label = toBackup.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 restic is enabled, snapshot the backup to a restic repository
var resticSummary: ResticWrapper.BackupSummary? = null
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
val binaryPath = ResticBinary.prepare(requireContext())
if (binaryPath != null) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
ResticWrapper.backendDomain = config.resticBackendDomain
// For local repos, verify init before attempting backup
if (config.resticBackend == "local") {
if (!File(config.resticRepo, "config").exists()) {
binding.statusText.text = "restic 本地仓库未初始化,请先在设置中初始化"
setRunning(false)
binding.scanButton.isEnabled = true
return@launch
}
try {
val outputDir = File(config.outputPath.ifEmpty {
requireContext().filesDir.absolutePath
})
WifiManager.backup(outputDir)
val result = BackupOperation.backupApps(
context = requireContext(),
apps = toBackup,
config = config,
outputDir = outputDir,
userId = selectedUserId.toString(),
onProgress = { progress ->
val label = toBackup.find { it.packageName == progress.packageName }?.label
val name = label?.ifEmpty { progress.packageName } ?: progress.packageName
binding.statusText.text =
"[${progress.current}/${progress.total}] $name: ${progress.message}"
}
binding.statusText.text = "正在写入 restic 去重仓库…"
val resticResult = ResticWrapper.backup(
repoPath = config.resticRepo,
password = config.resticPassword,
paths = listOf(result.outputDir),
tags = listOf("backup_${System.currentTimeMillis() / 1000}"),
hostname = "android-backup-gui",
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} 个文件"
)
// If restic is enabled, snapshot to 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) {
ResticWrapper.binaryPath = binaryPath
ResticWrapper.tempRepoDir = ResticBinary.getTempRepoDir(requireContext())
ResticWrapper.backendDomain = config.resticBackendDomain
if (config.resticBackend == "local") {
if (!File(config.resticRepo, "config").exists()) {
binding.statusText.text = "restic 本地仓库未初始化,请先在设置中初始化"
return@launch
}
}
binding.statusText.text = "正在写入 restic 去重仓库…"
val resticResult = ResticWrapper.backup(
repoPath = config.resticRepo,
password = config.resticPassword,
paths = listOf(result.outputDir),
tags = listOf("backup_${System.currentTimeMillis() / 1000}"),
hostname = "android-backup-gui",
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 = { progress ->
if (progress.messageType == "status") {
binding.statusText.text = "去重仓库: %.0f%% (%d/%d 个文件)".format(
progress.percentDone * 100,
progress.filesDone,
progress.totalFiles
)
}
}
},
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)}"
)
resticResult.fold(
onSuccess = { resticSummary = it },
onFailure = { e ->
resticError = e.message
binding.statusText.text = "restic 快照失败: ${e.message}"
}
},
onProgress = { progress ->
if (progress.messageType == "status") {
binding.statusText.text = "去重仓库: %.0f%% (%d/%d 个文件)".format(
progress.percentDone * 100,
progress.filesDone,
progress.totalFiles
)
}
}
)
resticResult.fold(
onSuccess = { resticSummary = it },
onFailure = { e ->
binding.statusText.text = "restic 快照失败: ${e.message}"
}
)
)
}
}
}
binding.statusText.text = buildString {
appendLine("备份完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
appendLine("耗时: ${result.elapsedMs / 1000}")
appendLine("输出: ${result.outputDir}")
if (resticSummary != null) {
appendLine()
appendLine("── Restic 快照 ──")
appendLine("ID: ${resticSummary!!.snapshotId.take(8)}")
appendLine("新增: ${resticSummary!!.dataAdded / 1024 / 1024} MB")
appendLine("文件: ${resticSummary!!.totalFilesProcessed}")
binding.statusText.text = buildString {
appendLine("备份完成!")
appendLine("成功: ${result.successCount} 失败: ${result.failCount}")
appendLine("耗时: ${result.elapsedMs / 1000}")
appendLine("输出: ${result.outputDir}")
if (resticSummary != null) {
appendLine()
appendLine("── Restic 快照 ──")
appendLine("ID: ${resticSummary!!.snapshotId.take(8)}")
appendLine("新增: ${resticSummary!!.dataAdded / 1024 / 1024} MB")
appendLine("文件: ${resticSummary!!.totalFilesProcessed}")
} else if (resticError != null) {
appendLine()
appendLine("── Restic 错误 ──")
appendLine(resticError!!)
}
}
} finally {
setRunning(false)
binding.scanButton.isEnabled = true
// Stop foreground service
try {
val stopIntent = Intent(requireContext(), BackupService::class.java)
stopIntent.action = BackupService.ACTION_STOP_BACKUP
requireContext().startService(stopIntent)
} catch (_: Exception) {}
}
setRunning(false)
binding.scanButton.isEnabled = true
}
}
private fun formatSize(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = arrayOf("KB", "MB", "GB", "TB")
val exp = (63 - bytes.countLeadingZeroBits()) / 10
val value = bytes.toDouble() / (1L shl (exp * 10))
return "%.1f %s".format(Locale.US, value, units[exp - 1].coerceAtMost(units.last()))
if (bytes <= 0) return "0 B"
val units = arrayOf("B", "KB", "MB", "GB")
val digitGroups = (Math.log10(bytes.toDouble()) / Math.log10(1024.0)).toInt()
return String.format(Locale.US, "%.1f %s", bytes / Math.pow(1024.0, digitGroups.toDouble()), units[digitGroups])
}
private fun setRunning(running: Boolean) {
@@ -198,10 +288,11 @@ class BackupFragment : Fragment() {
}
override fun onDestroyView() {
super.onDestroyView()
// Cleanup restic temp files when leaving the fragment
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
ResticWrapper.cleanup()
}
super.onDestroyView()
_binding = null
}
}

View File

@@ -4,7 +4,10 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
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 +24,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() {
@@ -32,6 +37,8 @@ class RestoreFragment : Fragment() {
private var selectedPackages = mutableSetOf<String>()
private var resticConfig: BackupConfig? = null
private var selectedSnapshot: ResticWrapper.ResticSnapshot? = null
private var selectedUserId: Int = 0
private var userList: List<Pair<Int, String>> = listOf(0 to "Owner")
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
@@ -42,6 +49,7 @@ class RestoreFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.appList.layoutManager = LinearLayoutManager(requireContext())
// Load restic config
@@ -63,6 +71,40 @@ class RestoreFragment : Fragment() {
binding.selectDirButton.setOnClickListener { selectBackupDir() }
binding.selectResticButton.setOnClickListener { selectResticSnapshot() }
binding.restoreButton.setOnClickListener { startRestore() }
// Load user profiles
loadUsers()
}
private fun loadUsers() {
viewLifecycleOwner.lifecycleScope.launch {
userList = AppScanner.enumerateUsers()
val names = userList.map { (id, name) -> "$name (ID: $id)" }
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.userSelector.adapter = adapter
binding.userSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedUserId = userList.getOrNull(position)?.first ?: 0
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
}
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() {
@@ -108,7 +150,7 @@ class RestoreFragment : Fragment() {
private fun selectResticSnapshot() {
val config = resticConfig ?: return
setRunning(true)
binding.statusText.text = "正在读取 restic 快照列表"
binding.statusText.text = "正在同步远程仓库到本地"
viewLifecycleOwner.lifecycleScope.launch {
val snapshotsResult = ResticWrapper.listSnapshots(
@@ -117,7 +159,13 @@ class RestoreFragment : Fragment() {
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare
backendShare = config.resticBackendShare,
onSyncProgress = { p ->
binding.statusText.text = "同步中: ${p.current}/${p.total} [${p.currentFile}]"
},
onByteSyncProgress = { bp ->
binding.statusText.text = "下载中: ${bp.bytesTransferred / 1024 / 1024} MB / ${bp.totalBytes / 1024 / 1024} MB"
}
)
if (snapshotsResult.isFailure) {
binding.statusText.text = "读取快照失败: ${snapshotsResult.exceptionOrNull()?.message}"
@@ -132,9 +180,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 +216,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 +229,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,71 +279,73 @@ 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,
userId = selectedUserId.toString(),
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
val r = RestoreOperation.restoreApps(
backupDir = dir,
userId = selectedUserId.toString(),
filterPkgs = selectedPackages,
onProgress = { progress ->
val label = appInfos.find { it.packageName == progress.packageName }?.label
@@ -297,22 +370,18 @@ class RestoreFragment : Fragment() {
}
}
private fun formatSize(bytes: Long): String {
if (bytes <= 0) return "0 B"
val units = arrayOf("B", "KB", "MB", "GB")
val digitGroups = (Math.log10(bytes.toDouble()) / Math.log10(1024.0)).toInt()
return String.format(Locale.US, "%.1f %s", bytes / Math.pow(1024.0, digitGroups.toDouble()), units[digitGroups])
}
private fun setRunning(running: Boolean) {
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
}
private fun formatSize(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = arrayOf("KB", "MB", "GB", "TB")
val exp = (63 - bytes.countLeadingZeroBits()) / 10
val value = bytes.toDouble() / (1L shl (exp * 10))
return "%.1f %s".format(Locale.US, value, units[exp - 1].coerceAtMost(units.last()))
}
override fun onDestroyView() {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
ResticWrapper.cleanup()
}
super.onDestroyView()
_binding = null
}

Binary file not shown.

Binary file not shown.

View File

@@ -29,6 +29,91 @@
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
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.BodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<Spinner
android:id="@+id/userSelector"
android:layout_width="0dp"
android:layout_height="wrap_content"
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">
<com.google.android.material.button.MaterialButton
android:id="@+id/sortAZButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:text="A-Z"
android:textSize="12sp"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/sortSizeButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="大小"
android:textSize="12sp"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/selectAllButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="全选"
android:textSize="12sp"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/deselectAllButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="取消全选"
android:textSize="12sp"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/showSystemSwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="显示系统应用"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:checked="false" />
</LinearLayout>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="match_parent"

View File

@@ -38,6 +38,27 @@
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
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.BodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<Spinner
android:id="@+id/userSelector"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
<TextView
android:id="@+id/backupDirText"
android:layout_width="match_parent"

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