Compare commits
9 Commits
v1.2-debug
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cdad04905 | ||
|
|
5cbd21577b | ||
|
|
1bae01de72 | ||
|
|
e710c36ee2 | ||
|
|
c1bbef4eef | ||
|
|
4c4542e059 | ||
|
|
ef78ab8bec | ||
|
|
a38a483c70 | ||
|
|
d0bfef41c8 |
@@ -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,6 +173,7 @@ object BackupOperation {
|
||||
|
||||
|
||||
private suspend fun backupUserData(
|
||||
context: android.content.Context,
|
||||
packageName: String,
|
||||
appDir: File,
|
||||
userId: String,
|
||||
@@ -160,101 +181,117 @@ object BackupOperation {
|
||||
): Boolean {
|
||||
val pkgEsc = packageName.shellEscape()
|
||||
val outputFile = "${appDir.absolutePath.shellEscape()}/${pkgEsc}_data.tar"
|
||||
val isZstd = compression == "zstd"
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
val archiveExt = if (isZstd) ".zst" else ".gz"
|
||||
val archiveRaw = File(appDir, "${packageName}_data.tar$archiveExt")
|
||||
|
||||
Log.d(TAG, "backupUserData: $packageName checking dirs")
|
||||
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 (app's mount namespace)
|
||||
val dirs = dataPaths.filter { RootShell.exec("test -d $it").isSuccess }.toMutableList()
|
||||
var result: RootShell.ShellResult? = null
|
||||
// 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)
|
||||
archiveCreated = result?.isSuccess == true
|
||||
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 {
|
||||
// 2. Try tar directly on direct paths (may fail in isolated namespace)
|
||||
Log.d(TAG, "backupUserData: $packageName test -d all failed, trying tar directly")
|
||||
result = runTar(dataPaths, outputFile, isZstd)
|
||||
archiveCreated = result?.isSuccess == true || (archiveRaw.exists() && archiveRaw.length() > 0)
|
||||
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. If still failed, try via /proc/1/root (global mount namespace)
|
||||
// Use cd to avoid tar packing the /proc/1/root/ prefix into the archive.
|
||||
// 3. Fallback via /proc/1/root (global mount namespace)
|
||||
if (!archiveCreated) {
|
||||
Log.w(TAG, "backupUserData: $packageName direct access failed, trying via /proc/1/root (global namespace)")
|
||||
val globalRelPaths = dataPaths.map { it.removePrefix("/") } // e.g. "data/data/tv.danmaku.bili"
|
||||
val excludeArgs = "--exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup'"
|
||||
Log.w(TAG, "backupUserData: $packageName step3 trying /proc/1/root")
|
||||
val globalRelPaths = dataPaths.map { it.removePrefix("/") }
|
||||
val globalCmd = if (isZstd) {
|
||||
"cd /proc/1/root && tar $excludeArgs -cf - ${globalRelPaths.joinToString(" ")} 2>/dev/null | zstd -T0 -o '$outputFile.zst'"
|
||||
"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 && tar $excludeArgs -czf '$outputFile.gz' ${globalRelPaths.joinToString(" ")} 2>/dev/null"
|
||||
"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 = result?.isSuccess == true || (archiveRaw.exists() && archiveRaw.length() > 0)
|
||||
}
|
||||
|
||||
// 4. Last resort: try su -Z (Magisk SELinux context switch) — on Magisk 30+,
|
||||
// the app's su context (untrusted_app) cannot see other apps' /data/data/.
|
||||
// Switching to u:r:magisk:s0 lifts the SELinux restriction.
|
||||
if (!archiveCreated) {
|
||||
Log.w(TAG, "backupUserData: $packageName /proc/1/root failed, trying su -Z magisk context")
|
||||
val excludeArgs = "--exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup'"
|
||||
val dirList = dataPaths.joinToString(" ")
|
||||
val rawOut = appDir.absolutePath + "/" + packageName + "_data.tar"
|
||||
val innerCmd = if (isZstd) {
|
||||
"tar $excludeArgs -cf - $dirList 2>/dev/null | zstd -T0 -o '${rawOut}.zst'"
|
||||
} else {
|
||||
"tar $excludeArgs -czf '${rawOut}.gz' $dirList 2>/dev/null"
|
||||
}
|
||||
result = RootShell.exec("su -Z u:r:magisk:s0 -c \"$innerCmd\" 2>/dev/null")
|
||||
archiveCreated = result?.isSuccess == true || (archiveRaw.exists() && archiveRaw.length() > 0)
|
||||
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 to backup (or inaccessible)")
|
||||
Log.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)")
|
||||
return true
|
||||
}
|
||||
// Verify integrity
|
||||
|
||||
// Verify compression integrity
|
||||
val verifyOk = if (isZstd) {
|
||||
RootShell.exec("zstd -t '$outputFile.zst' 2>/dev/null").isSuccess
|
||||
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
|
||||
}
|
||||
return verifyOk
|
||||
|
||||
// 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 (!tarValidateOk) {
|
||||
Log.e(TAG, "backupUserData: $packageName tar archive structure validation FAILED")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Run tar for given paths, building the appropriate zstd/gzip command. */
|
||||
private suspend fun runTar(
|
||||
dirs: List<String>,
|
||||
outputFile: String,
|
||||
isZstd: Boolean
|
||||
isZstd: Boolean,
|
||||
tarCmd: String = "tar",
|
||||
zstdCmd: String = "zstd",
|
||||
excludes: List<String> = emptyList()
|
||||
): RootShell.ShellResult {
|
||||
val excludeArgs = "--exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup'"
|
||||
val dirList = dirs.joinToString(" ")
|
||||
val excludeArgs = if (excludes.isNotEmpty()) {
|
||||
excludes.joinToString(" ") { "--exclude='$it'" }
|
||||
} else ""
|
||||
return if (isZstd) {
|
||||
RootShell.exec("tar $excludeArgs -cf - $dirList 2>/dev/null | zstd -T0 -o '$outputFile.zst'")
|
||||
RootShell.exec("$tarCmd -cf - $excludeArgs ${dirs.joinToString(" ")} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'")
|
||||
} else {
|
||||
RootShell.exec("tar $excludeArgs -czf '$outputFile.gz' $dirList 2>/dev/null")
|
||||
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}")
|
||||
@@ -266,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()
|
||||
}
|
||||
}
|
||||
@@ -69,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)
|
||||
@@ -125,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 {
|
||||
@@ -251,19 +255,36 @@ 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) {
|
||||
|
||||
@@ -34,22 +34,37 @@ object RootShell {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger root shell pre-initialization.
|
||||
* Returns true if root is available.
|
||||
* Note: Shell.cmd() also auto-initializes on first use, so this is optional.
|
||||
* 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 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
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun ensureSession(): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Shell.getShell().isRoot
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a shell command and return the result.
|
||||
* libsu internally runs via `su`, compatible with Magisk/KernelSU/APatch.
|
||||
* Commands are passed verbatim to `su -c`, so pipes and redirects work normally.
|
||||
* Timeout is enforced via structured coroutine cancellation.
|
||||
*/
|
||||
suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
|
||||
@@ -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,11 +58,51 @@ 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()
|
||||
// Re-read config so changes from ConfigFragment take effect immediately
|
||||
val configFile = File(requireContext().filesDir, "backup_settings.conf")
|
||||
config = BackupConfig.fromFile(configFile)
|
||||
}
|
||||
@@ -63,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 })
|
||||
|
||||
@@ -73,137 +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
|
||||
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
|
||||
|
||||
// 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 ->
|
||||
resticError = e.message
|
||||
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}")
|
||||
} else if (resticError != null) {
|
||||
appendLine()
|
||||
appendLine("── Restic 错误 ──")
|
||||
appendLine(resticError!!)
|
||||
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) {
|
||||
@@ -211,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,6 +4,8 @@ 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
|
||||
@@ -35,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?
|
||||
@@ -45,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
|
||||
@@ -66,6 +71,25 @@ 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() {
|
||||
@@ -126,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(
|
||||
@@ -135,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}"
|
||||
@@ -295,6 +325,7 @@ class RestoreFragment : Fragment() {
|
||||
|
||||
val r = RestoreOperation.restoreApps(
|
||||
backupDir = restoredBackupDir,
|
||||
userId = selectedUserId.toString(),
|
||||
filterPkgs = selectedPackages,
|
||||
onProgress = { progress ->
|
||||
val label = appInfos.find { it.packageName == progress.packageName }?.label
|
||||
@@ -314,6 +345,7 @@ class RestoreFragment : Fragment() {
|
||||
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
|
||||
@@ -338,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"
|
||||
|
||||
Reference in New Issue
Block a user