Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cdad04905 | ||
|
|
5cbd21577b | ||
|
|
1bae01de72 | ||
|
|
e710c36ee2 | ||
|
|
c1bbef4eef | ||
|
|
4c4542e059 | ||
|
|
ef78ab8bec | ||
|
|
a38a483c70 | ||
|
|
d0bfef41c8 | ||
|
|
0bde3b0a75 | ||
|
|
d2ea9f532f | ||
|
|
ac0fd8b063 | ||
|
|
fde6d05b83 | ||
|
|
2ff096ee8a | ||
|
|
eae7f4b369 | ||
|
|
420d960ce1 | ||
|
|
88e81e956c | ||
|
|
c12f7a9c81 | ||
|
|
a43638698c | ||
|
|
12fe29f841 | ||
|
|
b7addcca6b | ||
|
|
98d4029fd4 | ||
|
|
07366f744f | ||
|
|
88e18f4c57 | ||
|
|
d8992c3931 | ||
|
|
c7e9d8b5f1 | ||
|
|
3e1f8a5937 | ||
|
|
80b84f6cff | ||
|
|
0b017e853b | ||
|
|
2351cce99e |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -16,3 +16,9 @@ Thumbs.db
|
||||
# Keystore (regenerate if needed)
|
||||
debug.keystore
|
||||
release.keystore
|
||||
|
||||
# Memory files from agent harness
|
||||
memory:*
|
||||
|
||||
# Restic test repository (contains encryption keys)
|
||||
test/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **android-backup-gui** (933 symbols, 2388 relationships, 80 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **android-backup-gui** (922 symbols, 2334 relationships, 79 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **android-backup-gui** (933 symbols, 2388 relationships, 80 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **android-backup-gui** (922 symbols, 2334 relationships, 79 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -20,17 +20,22 @@ android {
|
||||
}
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile file("release.keystore")
|
||||
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "android"
|
||||
keyAlias "release"
|
||||
keyPassword System.getenv("KEY_PASSWORD") ?: "android"
|
||||
def keystoreFile = file("release.keystore")
|
||||
if (keystoreFile.exists()) {
|
||||
storeFile keystoreFile
|
||||
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "android"
|
||||
keyAlias "release"
|
||||
keyPassword System.getenv("KEY_PASSWORD") ?: "android"
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
signingConfig signingConfigs.release
|
||||
if (file("release.keystore").exists()) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
@@ -64,4 +69,7 @@ dependencies {
|
||||
implementation "eu.agno3.jcifs:jcifs-ng:2.1.10"
|
||||
implementation "com.github.thegrizzlylabs:sardine-android:v0.9"
|
||||
implementation "org.slf4j:slf4j-android:1.7.36"
|
||||
|
||||
// root shell via libsu (Magisk/KernelSU/APatch)
|
||||
implementation 'com.github.topjohnwu:libsu:6.0.0'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -80,11 +80,17 @@ class RemoteSyncManager {
|
||||
val deleted = cacheDir.deleteRecursively()
|
||||
Log.i(TAG, "cleanupTempDirs: deleted cache $cacheDir ($deleted)")
|
||||
}
|
||||
val tmpDir = File(tempRepoDir.substringBeforeLast("/") + "/restic_tmp")
|
||||
if (tmpDir.exists()) {
|
||||
val deleted = tmpDir.deleteRecursively()
|
||||
Log.i(TAG, "cleanupTempDirs: deleted tmp $tmpDir ($deleted)")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "cleanupTempDirs failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** True if [tempRepoDir] already contains an initialized restic repository (has a config file). */
|
||||
private fun isLocalRepoPopulated(): Boolean {
|
||||
if (tempRepoDir.isEmpty()) return false
|
||||
|
||||
@@ -148,16 +148,18 @@ interface RemoteTransport {
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
// Download remote files that are new or have different size
|
||||
var downloaded = 0
|
||||
var transferred = 0
|
||||
var skipped = 0
|
||||
val syncTotal = remoteFiles.size
|
||||
for ((relPath, info) in remoteByPath) {
|
||||
downloaded++
|
||||
onProgress(TransferProgress("download", downloaded, syncTotal, relPath))
|
||||
val localFile = File(localDir, relPath)
|
||||
if (localFile.isFile && localFile.length() == info.size) {
|
||||
Log.d(TAG, "syncFromRemote skip (same size): $relPath")
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
transferred++
|
||||
onProgress(TransferProgress("download", transferred, syncTotal, relPath))
|
||||
localFile.parentFile?.mkdirs()
|
||||
val fullRemotePath = "$remoteDir/$relPath"
|
||||
Log.i(TAG, "syncFromRemote downloading: $fullRemotePath (${info.size} bytes)")
|
||||
@@ -187,7 +189,7 @@ interface RemoteTransport {
|
||||
Log.i(TAG, "syncFromRemote deleting stale local: $relPath")
|
||||
try { localFile.delete() } catch (_: Exception) {}
|
||||
}
|
||||
onProgress(TransferProgress("complete", syncTotal, syncTotal))
|
||||
onProgress(TransferProgress("complete", transferred, syncTotal, "已传输: $transferred 跳过: $skipped"))
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("syncFromRemote failed: ${e.message}", e))
|
||||
@@ -233,15 +235,17 @@ interface RemoteTransport {
|
||||
|
||||
// Upload new or changed local files
|
||||
var uploaded = 0
|
||||
var uploadSkipped = 0
|
||||
val syncTotal = localFiles.size
|
||||
for ((relPath, localFile) in localFiles) {
|
||||
uploaded++
|
||||
onProgress(TransferProgress("upload", uploaded, syncTotal, relPath))
|
||||
val remoteInfo = remoteByPath[relPath]
|
||||
if (remoteInfo != null && remoteInfo.size == localFile.length()) {
|
||||
Log.d(TAG, "syncToRemote skip (same size): $relPath")
|
||||
uploadSkipped++
|
||||
continue
|
||||
}
|
||||
uploaded++
|
||||
onProgress(TransferProgress("upload", uploaded, syncTotal, relPath))
|
||||
val fullRemotePath = "$remoteDir/$relPath"
|
||||
Log.i(TAG, "syncToRemote uploading: $fullRemotePath (${localFile.length()} bytes)")
|
||||
val result = withRetry("upload($fullRemotePath)") {
|
||||
@@ -268,7 +272,7 @@ interface RemoteTransport {
|
||||
Log.i(TAG, "syncToRemote deleting stale: $relPath")
|
||||
transport.delete("$remoteDir/$relPath")
|
||||
}
|
||||
onProgress(TransferProgress("complete", localFiles.size, localFiles.size))
|
||||
onProgress(TransferProgress("complete", uploaded, syncTotal, "已传输: $uploaded 跳过: $uploadSkipped"))
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("syncToRemote failed: ${e.message}", e))
|
||||
|
||||
@@ -78,7 +78,7 @@ class ResticBackup(
|
||||
if (!line.startsWith("{")) continue
|
||||
try {
|
||||
val summary = resticJson.decodeFromString<ResticWrapper.BackupSummary>(line)
|
||||
if (summary.snapshotId.isNotEmpty()) return Result.success(summary)
|
||||
if (summary.messageType == "summary" && summary.snapshotId.isNotEmpty()) return Result.success(summary)
|
||||
} catch (_: Exception) { /* keep looking */ }
|
||||
}
|
||||
return Result.failure(Exception("No summary found in restic output"))
|
||||
|
||||
@@ -18,10 +18,8 @@ object ResticBinary {
|
||||
synchronized(this) {
|
||||
if (cacheInit) return cachedBinaryPath
|
||||
val nativeLibDir = context.applicationInfo.nativeLibraryDir
|
||||
Log.d(TAG, "nativeLibraryDir = $nativeLibDir")
|
||||
|
||||
val path = File(nativeLibDir, BINARY_NAME)
|
||||
Log.d(TAG, "restic: exists=${path.isFile} len=${path.length()} canExec=${path.canExecute()}")
|
||||
Log.d(TAG, "nativeLibraryDir=$nativeLibDir exists=${path.isFile} len=${path.length()} canExec=${path.canExecute()}")
|
||||
|
||||
cachedBinaryPath = if (path.isFile) {
|
||||
Log.i(TAG, "librestic.so ready at ${path.absolutePath} (${path.length()} bytes)")
|
||||
|
||||
@@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -38,7 +39,7 @@ class ResticCommandRunner {
|
||||
val cmdArgs = buildCommandArgs(args)
|
||||
Log.i(TAG, "runRestic cmd=${cmdArgs.joinToString(" ")}")
|
||||
Log.d(TAG, "runRestic REPOSITORY=${env["RESTIC_REPOSITORY"]}")
|
||||
|
||||
env["TMPDIR"]?.let { File(it).mkdirs() }
|
||||
return try {
|
||||
val pb = ProcessBuilder(cmdArgs)
|
||||
pb.environment().putAll(env)
|
||||
@@ -84,6 +85,7 @@ class ResticCommandRunner {
|
||||
val cmdArgs = buildCommandArgs(args)
|
||||
Log.i(TAG, "runResticStreaming cmd=${cmdArgs.joinToString(" ")}")
|
||||
Log.d(TAG, "runResticStreaming REPOSITORY=${env["RESTIC_REPOSITORY"]}")
|
||||
env["TMPDIR"]?.let { File(it).mkdirs() }
|
||||
|
||||
var process: Process? = null
|
||||
try {
|
||||
|
||||
@@ -29,6 +29,9 @@ class ResticEnvResolver {
|
||||
val cacheDir = tempRepoDir.substringBeforeLast("/") + "/restic_cache"
|
||||
env["HOME"] = cacheDir
|
||||
env["XDG_CACHE_HOME"] = cacheDir
|
||||
// Restic needs a writable temp dir for pack files. Android has no /tmp.
|
||||
val tmpDir = tempRepoDir.substringBeforeLast("/") + "/restic_tmp"
|
||||
env["TMPDIR"] = tmpDir
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
@@ -102,19 +102,20 @@ object ResticWrapper {
|
||||
|
||||
@Serializable
|
||||
data class BackupSummary(
|
||||
@SerialName("message_type") val messageType: String = "",
|
||||
@SerialName("snapshot_id") val snapshotId: String,
|
||||
@SerialName("files_new") val filesNew: Int,
|
||||
@SerialName("files_changed") val filesChanged: Int,
|
||||
@SerialName("files_unmodified") val filesUnmodified: Int,
|
||||
@SerialName("dirs_new") val dirsNew: Int,
|
||||
@SerialName("dirs_changed") val dirsChanged: Int,
|
||||
@SerialName("dirs_unmodified") val dirsUnmodified: Int,
|
||||
@SerialName("data_blobs") val dataBlobs: Int,
|
||||
@SerialName("tree_blobs") val treeBlobs: Int,
|
||||
@SerialName("data_added") val dataAdded: Long,
|
||||
@SerialName("total_files_processed") val totalFilesProcessed: Int,
|
||||
@SerialName("total_bytes_processed") val totalBytesProcessed: Long,
|
||||
@SerialName("total_duration") val totalDuration: Double
|
||||
@SerialName("files_new") val filesNew: Int = 0,
|
||||
@SerialName("files_changed") val filesChanged: Int = 0,
|
||||
@SerialName("files_unmodified") val filesUnmodified: Int = 0,
|
||||
@SerialName("dirs_new") val dirsNew: Int = 0,
|
||||
@SerialName("dirs_changed") val dirsChanged: Int = 0,
|
||||
@SerialName("dirs_unmodified") val dirsUnmodified: Int = 0,
|
||||
@SerialName("data_blobs") val dataBlobs: Int = 0,
|
||||
@SerialName("tree_blobs") val treeBlobs: Int = 0,
|
||||
@SerialName("data_added") val dataAdded: Long = 0,
|
||||
@SerialName("total_files_processed") val totalFilesProcessed: Int = 0,
|
||||
@SerialName("total_bytes_processed") val totalBytesProcessed: Long = 0,
|
||||
@SerialName("total_duration") val totalDuration: Double = 0.0
|
||||
)
|
||||
|
||||
suspend fun backup(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.example.androidbackupgui.backup
|
||||
import com.example.androidbackupgui.root.RootShell
|
||||
import com.example.androidbackupgui.root.shellEscape
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -18,6 +19,8 @@ import kotlinx.serialization.Serializable
|
||||
*/
|
||||
object RestoreOperation {
|
||||
|
||||
private const val TAG = "RestoreOperation"
|
||||
|
||||
@Serializable
|
||||
data class RestoreProgress(
|
||||
val current: Int,
|
||||
@@ -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) {
|
||||
|
||||
@@ -5,12 +5,15 @@ import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Backup and restore WiFi configuration.
|
||||
* Mirrors backup_script WiFi backup/restore logic.
|
||||
*/
|
||||
object WifiManager {
|
||||
private const val TAG = "WifiManager"
|
||||
|
||||
|
||||
// Possible WiFi config paths on different Android versions
|
||||
private val WIFI_PATHS = listOf(
|
||||
@@ -57,21 +60,27 @@ object WifiManager {
|
||||
// Try the most common path
|
||||
val fallback = "/data/misc/apexdata/com.android.wifi/WifiConfigStore.xml"
|
||||
val parent = File(fallback).parentFile?.absolutePath?.shellEscape() ?: return@withContext false
|
||||
RootShell.exec("mkdir -p '$parent'")
|
||||
val mkdirResult = RootShell.exec("mkdir -p '$parent'")
|
||||
if (!mkdirResult.isSuccess) return@withContext false
|
||||
val result = RootShell.exec("cp '$backupPath' '$fallback'")
|
||||
if (!result.isSuccess) return@withContext false
|
||||
RootShell.exec("chown system:wifi '$fallback'")
|
||||
RootShell.exec("chmod 0660 '$fallback'")
|
||||
val chownResult = RootShell.exec("chown system:wifi '$fallback'")
|
||||
if (!chownResult.isSuccess) Log.w(TAG, "chown failed: ${chownResult.error}")
|
||||
val chmodResult = RootShell.exec("chmod 0660 '$fallback'")
|
||||
if (!chmodResult.isSuccess) Log.w(TAG, "chmod failed: ${chmodResult.error}")
|
||||
} else {
|
||||
val result = RootShell.exec("cp '$backupPath' '$wifiTarget'")
|
||||
if (!result.isSuccess) return@withContext false
|
||||
RootShell.exec("chown system:wifi '$wifiTarget'")
|
||||
RootShell.exec("chmod 0660 '$wifiTarget'")
|
||||
val chownResult = RootShell.exec("chown system:wifi '$wifiTarget'")
|
||||
if (!chownResult.isSuccess) Log.w(TAG, "chown failed: ${chownResult.error}")
|
||||
val chmodResult = RootShell.exec("chmod 0660 '$wifiTarget'")
|
||||
if (!chmodResult.isSuccess) Log.w(TAG, "chmod failed: ${chmodResult.error}")
|
||||
}
|
||||
|
||||
// WiFi backup only takes effect after reboot, but we can try reloading
|
||||
RootShell.exec("svc wifi disable 2>/dev/null")
|
||||
RootShell.exec("svc wifi enable 2>/dev/null")
|
||||
// These are best-effort since reloading WiFi only takes full effect on reboot
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
package com.example.androidbackupgui.root
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.*
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.io.InputStream
|
||||
import android.util.Log
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
/**
|
||||
* Escape a string for safe use inside single-quoted shell strings.
|
||||
@@ -15,23 +14,16 @@ import android.util.Log
|
||||
fun String.shellEscape(): String = this.replace("'", "'\\''")
|
||||
|
||||
/**
|
||||
* Persistent root shell session via `su`.
|
||||
* Manages a single su process and executes commands sequentially.
|
||||
* Thread-safe via Mutex — all session state is guarded by the mutex.
|
||||
* Root shell access via libsu.
|
||||
* Shell.cmd internally manages su sessions, compatible with Magisk/KernelSU/APatch.
|
||||
* All shell operations are thread-safe through coroutine dispatchers.
|
||||
*/
|
||||
object RootShell {
|
||||
|
||||
private var process: Process? = null
|
||||
private var writer: OutputStreamWriter? = null
|
||||
private var reader: BufferedReader? = null
|
||||
private var errReader: BufferedReader? = null
|
||||
|
||||
private const val TAG = "RootShell"
|
||||
/** Default command timeout in milliseconds. */
|
||||
private const val COMMAND_TIMEOUT_MS = 120_000L
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
/** Result of a shell command execution. */
|
||||
data class ShellResult(
|
||||
val output: String,
|
||||
@@ -41,134 +33,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
BIN
app/src/main/jniLibs/arm64-v8a/libtar_bin.so
Normal file
BIN
app/src/main/jniLibs/arm64-v8a/libtar_bin.so
Normal file
Binary file not shown.
BIN
app/src/main/jniLibs/arm64-v8a/libzstd_bin.so
Normal file
BIN
app/src/main/jniLibs/arm64-v8a/libzstd_bin.so
Normal file
Binary file not shown.
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# 用户偏好
|
||||
- 交流语言:所有回复必须使用中文。这是核心要求,新对话需自动加载。
|
||||
- 项目:Android Backup GUI(Kotlin 应用)
|
||||
- 工作目录:~/github_projects/android-backup-gui
|
||||
|
||||
# 项目背景
|
||||
Android Backup GUI(Kotlin app with native root execution, SMB/WebDAV remote storage, WiFi backup)和 CodeGraph global setup(MCP server for code intelligence, auto-init prompt in CLAUDE.md)。
|
||||
|
||||
# 技术要点
|
||||
- root shell persistence
|
||||
- SMB troubleshooting (ECONNREFUSED)
|
||||
- 构建命令: ./gradlew assembleDebug
|
||||
Reference in New Issue
Block a user