refactor: replace RemoteStorage with restic rclone backend, redesign config UI

Remove the old WebDAV/SMB remote storage layer (RemoteStorage, WebDavClient,
SmbClient) and replace it with restic's built-in rclone backend. Unify the
two config cards into a single "Restic remote backup" card with backend
selector (local/WebDAV/SMB). Fix binary execution on Android 10+ via linker
trampoline to bypass W^X restrictions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sakuradairong
2026-05-23 14:26:13 +08:00
parent 006c598f14
commit fdc147b663
40 changed files with 3608 additions and 98 deletions

View File

@@ -1,15 +0,0 @@
- [ ] Clarify Project Requirements
- [x] Scaffold the Project
- [ ] Customize the Project
- [ ] Install Required Extensions
- [ ] Compile the Project
- [ ] Create and Run Task
- [ ] Launch the Project
- [ ] Ensure Documentation is Complete
# 项目说明
本项目为 backup_script 脚本提供 Android 图形化操作界面,支持本地运行脚本、参数配置、结果展示。
## 进度说明
- 已完成项目结构搭建与主要文件生成。
- 下一步将完善脚本调用、参数配置与界面交互细节。

View File

@@ -1,44 +0,0 @@
package com.example.androidbackupgui
import android.os.Bundle
import android.widget.*
import androidx.appcompat.app.AppCompatActivity
import java.io.BufferedReader
import java.io.DataOutputStream
import java.io.InputStreamReader
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val paramInput = findViewById<EditText>(R.id.paramInput)
val runButton = findViewById<Button>(R.id.runButton)
val resultView = findViewById<TextView>(R.id.resultView)
runButton.setOnClickListener {
val params = paramInput.text.toString()
val result = runShellScript(params)
resultView.text = result
}
}
private fun runShellScript(params: String): String {
// 假设脚本已复制到 /data/data/com.example.androidbackupgui/files/scripts/tools.sh
val scriptPath = filesDir.absolutePath + "/scripts/tools.sh"
val cmd = "sh $scriptPath $params"
return try {
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", cmd))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val output = StringBuilder()
var line: String?
while (reader.readLine().also { line = it } != null) {
output.append(line).append("\n")
}
reader.close()
output.toString()
} catch (e: Exception) {
"执行失败: ${e.message}"
}
}
}

View File

@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<EditText
android:id="@+id/paramInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="输入脚本参数" />
<Button
android:id="@+id/runButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="运行脚本" />
<TextView
android:id="@+id/resultView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:text="结果输出..."
android:background="#EEE"
android:padding="8dp" />
</LinearLayout>

View File

@@ -2,20 +2,31 @@ apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
compileSdkVersion 34
namespace "com.example.androidbackupgui"
compileSdk 34
defaultConfig {
applicationId "com.example.androidbackupgui"
minSdkVersion 24
targetSdkVersion 34
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0"
}
buildFeatures {
viewBinding true
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
@@ -24,4 +35,9 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.fragment:fragment-ktx:1.6.2'
implementation 'androidx.viewpager2:viewpager2:1.0.0'
}

1
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1 @@
# Add project specific ProGuard rules here.

View File

@@ -1,15 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.androidbackupgui">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.AppCompat.Light.DarkActionBar">
<activity android:name=".MainActivity">
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

Binary file not shown.

View File

@@ -0,0 +1,74 @@
package com.example.androidbackupgui
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.example.androidbackupgui.databinding.ActivityMainBinding
import com.example.androidbackupgui.ui.BackupFragment
import com.example.androidbackupgui.ui.ConfigFragment
import com.example.androidbackupgui.ui.RestoreFragment
import com.google.android.material.color.DynamicColors
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val pageTitles = listOf("應用備份", "應用恢復", "備份配置")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
DynamicColors.applyToActivitiesIfAvailable(application)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// Edge-to-edge: pad toolbar below status bar
ViewCompat.setOnApplyWindowInsetsListener(binding.topAppBar) { view, insets ->
val statusBars = insets.getInsets(WindowInsetsCompat.Type.statusBars())
view.setPadding(view.paddingLeft, statusBars.top, view.paddingRight, view.paddingBottom)
insets
}
val fragments = listOf(
BackupFragment(),
RestoreFragment(),
ConfigFragment()
)
binding.viewPager.adapter = TabAdapter(this, fragments)
binding.viewPager.isUserInputEnabled = true
binding.bottomNav.setOnItemSelectedListener { item ->
when (item.itemId) {
R.id.nav_backup -> binding.viewPager.currentItem = 0
R.id.nav_restore -> binding.viewPager.currentItem = 1
R.id.nav_config -> binding.viewPager.currentItem = 2
}
true
}
// Sync ViewPager -> BottomNav + Toolbar title
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
binding.bottomNav.menu.getItem(position).isChecked = true
binding.topAppBar.title = pageTitles[position]
}
})
}
private class TabAdapter(
activity: FragmentActivity,
private val fragments: List<Fragment>
) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = fragments.size
override fun createFragment(position: Int): Fragment = fragments[position]
}
}

View File

@@ -0,0 +1,103 @@
package com.example.androidbackupgui.backup
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
data class AppInfo(
val packageName: String,
val label: String = "",
val isSystem: Boolean = false,
val apkPaths: List<String> = emptyList(),
val hasObb: Boolean = false,
val isRunning: Boolean = false,
val backupSize: Long = 0 // estimated from last backup
)
object AppScanner {
/** Scan all third-party installed packages. */
suspend fun scanThirdParty(): List<AppInfo> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list packages -3")
if (!result.isSuccess) return@withContext emptyList()
result.output.lines()
.filter { it.startsWith("package:") }
.map { it.removePrefix("package:").trim() }
.filter { it.isNotEmpty() }
.map { AppInfo(packageName = it) }
}
/** Scan all system packages. */
suspend fun scanSystem(config: BackupConfig): List<AppInfo> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list packages -s")
if (!result.isSuccess) return@withContext emptyList()
val systemWhitelist = config.system.toSet()
val dataWhitelist = config.whitelist.toSet()
val blacklist = config.blacklist.toSet()
result.output.lines()
.filter { it.startsWith("package:") }
.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) }
}
/** Get APK paths for a package. */
suspend fun getApkPaths(packageName: String): List<String> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm path '${packageName.shellEscape()}'")
if (!result.isSuccess) return@withContext emptyList()
result.output.lines()
.filter { it.startsWith("package:") }
.map { it.removePrefix("package:").trim() }
.filter { it.isNotEmpty() }
}
/** Get the app label/name. */
suspend fun getAppLabel(packageName: String): String = withContext(Dispatchers.IO) {
val result = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep -A1 'ApplicationInfo' | grep 'label=' | head -1")
val label = result.output
.substringAfter("label=", "")
.substringBefore(" ")
.removeSurrounding("\"")
.trim()
label.ifEmpty { packageName }
}
/** Check if a package has OBB data. */
suspend fun hasObbData(packageName: String): Boolean = withContext(Dispatchers.IO) {
val result = RootShell.exec("ls /storage/emulated/0/Android/obb/${packageName.shellEscape()}/ 2>/dev/null")
result.output.isNotBlank()
}
/** Check if a package is currently running. */
suspend fun isPackageRunning(packageName: String): Boolean = withContext(Dispatchers.IO) {
val result = RootShell.exec("pidof '${packageName.shellEscape()}'")
result.output.isNotBlank()
}
/** Apply appList.txt-style filters. Lines starting with # are ignored, ! means apk-only. */
fun parseAppList(content: String): List<Pair<String, Boolean>> {
return content.lines()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
.map { line ->
if (line.startsWith("!")) {
line.removePrefix("!").trim() to false // apk only (no data)
} else {
line.trim() to true // full backup
}
}
}
}

View File

@@ -0,0 +1,179 @@
package com.example.androidbackupgui.backup
import java.io.File
/**
* Mirrors backup_settings.conf from backup_script.
* All keys correspond 1:1 with the original shell config.
*/
data class BackupConfig(
// Operation mode
var lo: Int = 0, // 0=volume key, 1=volume force, 2=keyboard
var backgroundExecution: Int = 0, // 0=foreground, 1=background
var setDisplayPowerMode: Int = 0, // 1=keep screen on during backup
var shellLang: String = "", // ""=auto, "1"=zh-CN, "0"=zh-TW
// Paths
var outputPath: String = "", // Custom output dir
var listLocation: String = "", // Custom appList.txt location
// Update
var update: Int = 1, // 1=auto update
var cdn: Int = 1, // CDN node
// Filters
var mountPoint: String = "rannki|0000-1",
var user: String = "",
// Backup mode
var backupMode: Int = 1, // 1=data+apk, 0=apk only
var backupUserData: Int = 1,
var backupObbData: Int = 1,
var backupMedia: Int = 0,
var backgroundAppsIgnore: Int = 0,
// Custom paths
var customPath: List<String> = listOf(
"/storage/emulated/0/Pictures/",
"/storage/emulated/0/Download/",
"/storage/emulated/0/Music",
"/storage/emulated/0/DCIM/",
"/data/adb"
),
// Blacklist
var blacklistMode: Int = 0, // 1=full ignore, 0=apk only
var blacklist: List<String> = emptyList(),
// Whitelists
var whitelist: List<String> = emptyList(),
var system: List<String> = emptyList(),
// Compression
var compressionMethod: String = "zstd", // zstd or tar
// Terminal colors
var rgbA: Int = 226,
var rgbB: Int = 123,
var rgbC: Int = 177,
var backupWifi: Int = 1,
// Restic deduplicated backup with rclone backend
var resticEnabled: Int = 0,
var resticRepo: String = "",
var resticPassword: String = "",
var resticBackend: String = "local", // local / webdav / smb
var resticBackendUrl: String = "",
var resticBackendUser: String = "",
var resticBackendPass: String = ""
) {
companion object {
fun fromFile(file: File): BackupConfig {
val config = BackupConfig()
if (!file.exists()) return config
val props = mutableMapOf<String, String>()
file.forEachLine { line ->
val trimmed = line.trim()
if (trimmed.isEmpty() || trimmed.startsWith("#")) return@forEachLine
val eq = trimmed.indexOf('=')
if (eq < 0) return@forEachLine
val key = trimmed.substring(0, eq).trim()
val value = trimmed.substring(eq + 1).trim().removeSurrounding("\"")
props[key] = value
}
fun int(key: String, default: Int = 0) = props[key]?.toIntOrNull() ?: default
fun str(key: String) = props[key] ?: ""
fun lines(key: String): List<String> {
val raw = props[key] ?: return emptyList()
return raw.split("\\s+".toRegex())
.filter { it.isNotBlank() && it != "\"\"" }
.map { it.replace("%20", " ") }
}
config.lo = int("Lo")
config.backgroundExecution = int("background_execution")
config.setDisplayPowerMode = int("setDisplayPowerMode")
config.shellLang = str("Shell_LANG")
config.outputPath = str("Output_path")
config.listLocation = str("list_location")
config.update = int("update", default = 1)
config.cdn = int("cdn", default = 1)
config.mountPoint = str("mount_point")
config.user = str("user")
config.backupMode = int("Backup_Mode", default = 1)
config.backupUserData = int("Backup_user_data", default = 1)
config.backupObbData = int("Backup_obb_data", default = 1)
config.backupMedia = int("backup_media")
config.backgroundAppsIgnore = int("Background_apps_ignore")
config.customPath = lines("Custom_path")
config.blacklistMode = int("blacklist_mode")
config.blacklist = lines("blacklist")
config.whitelist = lines("whitelist")
config.system = lines("system")
config.compressionMethod = str("Compression_method").ifEmpty { "zstd" }
config.rgbA = int("rgb_a").let { if (it == 0) 226 else it }
config.rgbB = int("rgb_b").let { if (it == 0) 123 else it }
config.rgbC = int("rgb_c").let { if (it == 0) 177 else it }
config.backupWifi = int("backup_wifi", default = 1)
config.resticEnabled = int("restic_enabled")
config.resticRepo = str("restic_repo")
config.resticPassword = str("restic_password")
config.resticBackend = str("restic_backend").ifEmpty { "local" }
config.resticBackendUrl = str("restic_backend_url")
config.resticBackendUser = str("restic_backend_user")
config.resticBackendPass = str("restic_backend_pass")
return config
}
fun toFile(config: BackupConfig, file: File) {
file.parentFile?.mkdirs()
file.writeText(buildString {
appendLine("# SpeedBackup Configuration")
appendLine("Lo=${config.lo}")
appendLine("background_execution=${config.backgroundExecution}")
appendLine("setDisplayPowerMode=${config.setDisplayPowerMode}")
appendLine("Shell_LANG=${config.shellLang}")
appendLine("Output_path=\"${config.outputPath}\"")
appendLine("list_location=\"${config.listLocation}\"")
appendLine("update=${config.update}")
appendLine("cdn=${config.cdn}")
appendLine("mount_point=\"${config.mountPoint}\"")
appendLine("user=${config.user}")
appendLine("Backup_Mode=${config.backupMode}")
appendLine("Backup_user_data=${config.backupUserData}")
appendLine("Backup_obb_data=${config.backupObbData}")
appendLine("backup_media=${config.backupMedia}")
appendLine("Background_apps_ignore=${config.backgroundAppsIgnore}")
append("Custom_path=\"")
config.customPath.forEach { append(" ${it.replace(" ", "%20")}") }
appendLine(" \"")
appendLine("blacklist_mode=${config.blacklistMode}")
append("blacklist=\"")
config.blacklist.forEach { append(" ${it.replace(" ", "%20")}") }
appendLine(" \"")
append("whitelist=\"")
config.whitelist.forEach { append(" ${it.replace(" ", "%20")}") }
appendLine(" \"")
append("system=\"")
config.system.forEach { append(" ${it.replace(" ", "%20")}") }
appendLine(" \"")
appendLine("Compression_method=${config.compressionMethod}")
appendLine("rgb_a=${config.rgbA}")
appendLine("rgb_b=${config.rgbB}")
appendLine("rgb_c=${config.rgbC}")
appendLine("backup_wifi=${config.backupWifi}")
appendLine("restic_enabled=${config.resticEnabled}")
appendLine("restic_repo=\"${config.resticRepo}\"")
appendLine("restic_password=\"${config.resticPassword}\"")
appendLine("restic_backend=${config.resticBackend}")
appendLine("restic_backend_url=\"${config.resticBackendUrl}\"")
appendLine("restic_backend_user=\"${config.resticBackendUser}\"")
appendLine("restic_backend_pass=\"${config.resticBackendPass}\"")
})
}
}
}

View File

@@ -0,0 +1,210 @@
package com.example.androidbackupgui.backup
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import org.json.JSONObject
import kotlin.coroutines.coroutineContext
/**
* Performs backup of apps and WiFi config using root shell.
* Mirrors the logic from backup_script's modules/backup.sh.
*/
object BackupOperation {
data class BackupProgress(
val current: Int,
val total: Int,
val packageName: String,
val stage: String, // "apk", "data", "obb", "ssaid", "done"
val message: String
)
data class BackupResult(
val successCount: Int,
val failCount: Int,
val skippedCount: Int,
val outputDir: String,
val elapsedMs: Long
)
/**
* Backup a list of apps to the output directory.
* @param apps list of AppInfo to backup
* @param config backup configuration
* @param outputDir root output directory
* @param userId Android user ID (0, 999, etc.)
* @param onProgress callback for UI updates
*/
suspend fun backupApps(
apps: List<AppInfo>,
config: BackupConfig,
outputDir: File,
userId: String = "0",
onProgress: suspend (BackupProgress) -> Unit = {}
): BackupResult = withContext(Dispatchers.IO) {
val emit: suspend (BackupProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
val startTime = System.currentTimeMillis()
// Create backup structure
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
backupRoot.mkdirs()
// Write app list
val appListFile = File(backupRoot, "appList.txt")
appListFile.writeText(apps.joinToString("\n") { it.packageName })
// Write metadata JSON
val metaFile = File(backupRoot, "app_details.json")
metaFile.writeText(buildAppDetailsJson(apps))
var success = 0
var fail = 0
var skipped = 0
for ((index, app) in apps.withIndex()) {
if (!coroutineContext.isActive) break
val appDir = File(backupRoot, app.packageName)
appDir.mkdirs()
emit(BackupProgress(index + 1, apps.size, app.packageName, "apk", "正在備份 APK…"))
// 1. Backup APK
val paths = AppScanner.getApkPaths(app.packageName)
val apkOk = if (paths.isNotEmpty()) {
paths.withIndex().all { (i, apkPath) ->
val destName = if (paths.size > 1) "${app.packageName}_split_$i.apk" else "${app.packageName}.apk"
RootShell.exec("cp '${apkPath.shellEscape()}' '${appDir.absolutePath.shellEscape()}/${destName.shellEscape()}'").isSuccess
}
} else false
if (!apkOk) {
fail++
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "APK 備份失敗"))
continue
}
// 2. Backup user data (if configured)
if (config.backupMode == 1 && config.backupUserData == 1) {
emit(BackupProgress(index + 1, apps.size, app.packageName, "data", "正在備份數據…"))
backupUserData(app.packageName, appDir, userId, config.compressionMethod)
}
// 3. Backup OBB (if configured and exists)
if (config.backupMode == 1 && config.backupObbData == 1) {
val hasObb = AppScanner.hasObbData(app.packageName)
if (hasObb) {
emit(BackupProgress(index + 1, apps.size, app.packageName, "obb", "正在備份 OBB…"))
backupObb(app.packageName, appDir, config.compressionMethod)
}
}
// 4. Backup SSAID
emit(BackupProgress(index + 1, apps.size, app.packageName, "ssaid", "正在備份 SSAID…"))
backupSsaid(app.packageName, appDir, userId)
// 5. Backup runtime permissions
backupPermissions(app.packageName, appDir)
success++
emit(BackupProgress(index + 1, apps.size, app.packageName, "done", "完成"))
}
val elapsed = System.currentTimeMillis() - startTime
RootShell.exec("chmod -R 0755 '${backupRoot.absolutePath}'")
BackupResult(
successCount = success,
failCount = fail,
skippedCount = skipped,
outputDir = backupRoot.absolutePath,
elapsedMs = elapsed
)
}
private fun backupUserData(
packageName: String,
appDir: File,
userId: String,
compression: String
) {
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
// Exclude cache, code_cache, lib
val excludeArgs = "--exclude='cache' --exclude='code_cache' --exclude='lib' --exclude='no_backup'"
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"
)
}
}
}
private fun backupObb(packageName: String, appDir: File, compression: String) {
val obbDir = "/storage/emulated/0/Android/obb/${packageName.shellEscape()}"
val escapedAppDir = appDir.absolutePath.shellEscape()
val escapedPkg = packageName.shellEscape()
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")
}
}
private 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)
}
}
private fun backupPermissions(packageName: String, appDir: File) {
val result = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep -E 'granted=true|permission' | head -50")
if (result.output.isNotBlank()) {
File(appDir, "permissions.txt").writeText(result.output)
}
}
private fun buildAppDetailsJson(apps: List<AppInfo>): String {
val root = JSONObject()
for (app in apps) {
val entry = JSONObject()
entry.put("label", app.label)
entry.put("isSystem", app.isSystem)
root.put(app.packageName, entry)
}
return root.toString(2)
}
private fun captureSsaid(packageName: String, userId: String): String {
return RootShell.exec("grep '${packageName.shellEscape()}' '/data/system/users/${userId.shellEscape()}/settings_ssaid.xml' 2>/dev/null").output
}
private fun capturePermissions(packageName: String): String {
return RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep -E 'granted=true|permission' | head -50").output
}
}

View File

@@ -0,0 +1,110 @@
package com.example.androidbackupgui.backup
import android.content.Context
import java.io.File
import java.io.FileOutputStream
/**
* Manages the restic native binary on the device.
*
* On first run, extracts the correct ABI binary from APK assets to the app's
* filesDir and sets executable permission. On subsequent runs, reuses the
* already-extracted binary.
*
* Expected asset layout:
* assets/restic/arm64-v8a/restic
* assets/restic/armeabi-v7a/restic
* assets/restic/x86_64/restic
*/
object ResticBinary {
private const val ASSET_PREFIX = "restic"
private const val BINARY_NAME = "restic"
@Volatile
private var extractedPath: String? = null
/**
* Prepare the restic binary and return its absolute path.
* Extracts from assets if not already present.
*
* @return the absolute path to the restic binary, or null if extraction failed.
*/
fun prepare(context: Context): String? {
extractedPath?.let { path ->
if (File(path).exists() && File(path).canExecute()) return path
}
val abi = detectAbi()
val destFile = File(context.filesDir, BINARY_NAME)
// If already extracted and executable for this ABI, reuse
if (destFile.exists() && destFile.canExecute()) {
extractedPath = destFile.absolutePath
return extractedPath
}
val assetPath = "$ASSET_PREFIX/$abi/$BINARY_NAME"
val success = try {
context.assets.open(assetPath).use { input ->
FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
// Set executable (may need root on some devices)
destFile.setExecutable(true, false)
destFile.setReadable(true, false)
// Verify
destFile.canExecute() && destFile.length() > 0
} catch (e: Exception) {
// Asset not found for this ABI — try fallback
tryFallbackExtraction(context, destFile)
}
if (!success) return null
extractedPath = destFile.absolutePath
return extractedPath
}
/** Try extracting from alternate ABI asset paths. */
private fun tryFallbackExtraction(context: Context, destFile: File): Boolean {
val fallbackAbis = listOf("arm64-v8a", "armeabi-v7a", "x86_64", "x86")
for (abi in fallbackAbis) {
try {
context.assets.open("$ASSET_PREFIX/$abi/$BINARY_NAME").use { input ->
FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
destFile.setExecutable(true, false)
destFile.setReadable(true, false)
if (destFile.canExecute() && destFile.length() > 0) return true
} catch (_: Exception) {
// try next
}
}
return false
}
/**
* Check if the binary is ready. Does NOT extract.
*/
fun isReady(): Boolean {
val path = extractedPath ?: return false
return File(path).canExecute()
}
/** Detect the device ABI from Build.SUPPORTED_ABIS. */
private fun detectAbi(): String {
val abis = android.os.Build.SUPPORTED_ABIS ?: arrayOf("arm64-v8a")
return when {
abis.any { it.contains("arm64") || it.contains("aarch64") } -> "arm64-v8a"
abis.any { it.contains("armeabi") } -> "armeabi-v7a"
abis.any { it.contains("x86_64") } -> "x86_64"
abis.any { it.contains("x86") } -> "x86"
else -> abis.firstOrNull() ?: "arm64-v8a"
}
}
}

View File

@@ -0,0 +1,488 @@
package com.example.androidbackupgui.backup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import kotlin.coroutines.coroutineContext
/**
* Wraps the restic CLI binary for backup/restore operations.
*
* Uses environment variables (RESTIC_REPOSITORY, RESTIC_PASSWORD) rather than
* command-line flags to avoid leaking secrets in the process list.
*
* All public methods are suspend and run on Dispatchers.IO.
*/
object ResticWrapper {
/** Path to the restic binary. Default assumes it's on PATH (e.g. Termux). */
var binaryPath: String = "restic"
// ── Progress data ──────────────────────────────────
data class ResticProgress(
val messageType: String, // "status" during backup
val percentDone: Double = 0.0,
val totalFiles: Int = 0,
val filesDone: Int = 0,
val totalBytes: Long = 0,
val bytesDone: Long = 0,
val currentFiles: List<String> = emptyList()
)
data class ResticSnapshot(
val id: String,
val shortId: String,
val time: String,
val paths: List<String>,
val tags: List<String>,
val hostname: String = ""
)
// ── Repository lifecycle ───────────────────────────
suspend fun init(
repoPath: String,
password: String,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = ""
): Result<Unit> =
withContext(Dispatchers.IO) {
val env = buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass)
val result = runRestic(env, "init")
if (result.exitCode == 0 || result.exitCode == 1) {
// exit code 1 = already initialized, which is fine
Result.success(Unit)
} else {
Result.failure(Exception("restic init failed: ${result.stderr}"))
}
}
// ── Backup ─────────────────────────────────────────
data class BackupSummary(
val snapshotId: String,
val filesNew: Int,
val filesChanged: Int,
val filesUnmodified: Int,
val dirsNew: Int,
val dirsChanged: Int,
val dirsUnmodified: Int,
val dataBlobs: Int,
val treeBlobs: Int,
val dataAdded: Long,
val totalFilesProcessed: Int,
val totalBytesProcessed: Long,
val totalDuration: Double
)
suspend fun backup(
repoPath: String,
password: String,
paths: List<String>,
tags: List<String> = emptyList(),
hostname: String? = null,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = "",
onProgress: suspend (ResticProgress) -> Unit = {}
): Result<BackupSummary> = withContext(Dispatchers.IO) {
val emit: suspend (ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
val args = mutableListOf("backup", "--json")
for (path in paths) args.add(path)
for (tag in tags) { args.add("--tag"); args.add(tag) }
if (hostname != null) { args.add("--host"); args.add(hostname) }
val env = buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass)
val result = runResticStreaming(env, args) { line ->
if (!coroutineContext.isActive) return@runResticStreaming
try {
val json = JSONObject(line)
val msgType = json.optString("message_type", "")
if (msgType == "status") {
val currentFiles = mutableListOf<String>()
json.optJSONArray("current_files")?.let { arr ->
for (i in 0 until arr.length()) currentFiles.add(arr.getString(i))
}
emit(ResticProgress(
messageType = "status",
percentDone = json.optDouble("percent_done", 0.0),
totalFiles = json.optInt("total_files", 0),
filesDone = json.optInt("files_done", 0),
totalBytes = json.optLong("total_bytes", 0),
bytesDone = json.optLong("bytes_done", 0),
currentFiles = currentFiles
))
}
} catch (_: Exception) { /* ignore non-JSON lines */ }
}
if (result.exitCode != 0) {
return@withContext Result.failure(Exception("restic backup failed: ${result.stderr}"))
}
// Parse the summary JSON on the last line(s) of stdout
parseBackupSummary(result.stdout).fold(
onSuccess = { Result.success(it) },
onFailure = { Result.failure(it) }
)
}
// ── Restore ────────────────────────────────────────
suspend fun restore(
repoPath: String,
password: String,
snapshotId: String,
targetPath: String,
include: String? = null,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = "",
onProgress: suspend (String) -> Unit = {}
): Result<Unit> = withContext(Dispatchers.IO) {
val emit: suspend (String) -> Unit = { s -> withContext(Dispatchers.Main) { onProgress(s) } }
File(targetPath).mkdirs()
val args = mutableListOf("restore", snapshotId, "--target", targetPath, "--json")
if (include != null) { args.add("--include"); args.add(include) }
val env = buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass)
val result = runResticStreaming(env, args) { line ->
if (!coroutineContext.isActive) return@runResticStreaming
try {
val json = JSONObject(line)
val msgType = json.optString("message_type", "")
if (msgType == "status") {
val percent = "%.1f".format(json.optDouble("percent_done", 0.0) * 100)
emit("恢復進度: $percent%")
} else if (msgType == "summary") {
emit("恢復完成: ${json.optInt("total_files", 0)} 個檔案")
}
} catch (_: Exception) { emit(line) }
}
if (result.exitCode == 0) Result.success(Unit)
else Result.failure(Exception("restic restore failed: ${result.stderr}"))
}
// ── Snapshot listing ───────────────────────────────
suspend fun listSnapshots(
repoPath: String,
password: String,
tag: String? = null,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = ""
): Result<List<ResticSnapshot>> = withContext(Dispatchers.IO) {
val args = mutableListOf("snapshots", "--json")
if (tag != null) { args.add("--tag"); args.add(tag) }
val env = buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass)
val result = runRestic(env, args)
if (result.exitCode != 0) {
return@withContext Result.failure(Exception("restic snapshots failed: ${result.stderr}"))
}
try {
val jsonArray = JSONArray(result.stdout.ifEmpty { "[]" })
val snapshots = (0 until jsonArray.length()).map { i ->
val obj = jsonArray.getJSONObject(i)
val pathsArr: JSONArray? = obj.optJSONArray("paths")
val tagsArr: JSONArray? = obj.optJSONArray("tags")
val pathsList: List<String> = if (pathsArr != null)
(0 until pathsArr.length()).map { j -> pathsArr.getString(j) }
else emptyList()
val tagsList: List<String> = if (tagsArr != null)
(0 until tagsArr.length()).map { j -> tagsArr.getString(j) }
else emptyList()
ResticSnapshot(
id = obj.optString("id", ""),
shortId = obj.optString("short_id", ""),
time = obj.optString("time", ""),
paths = pathsList,
tags = tagsList,
hostname = obj.optString("hostname", "")
)
}
Result.success(snapshots.sortedByDescending { it.time })
} catch (e: Exception) {
Result.failure(Exception("Failed to parse snapshot JSON: ${e.message}"))
}
}
// ── Maintenance ────────────────────────────────────
suspend fun forget(
repoPath: String,
password: String,
keepDaily: Int = 7,
keepWeekly: Int = 4,
keepMonthly: Int = 3,
dryRun: Boolean = false,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = ""
): Result<String> = withContext(Dispatchers.IO) {
val args = mutableListOf(
"forget",
"--keep-daily", keepDaily.toString(),
"--keep-weekly", keepWeekly.toString(),
"--keep-monthly", keepMonthly.toString()
)
if (dryRun) args.add("--dry-run")
val env = buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass)
val result = runRestic(env, args)
if (result.exitCode == 0) Result.success(result.stdout)
else Result.failure(Exception("restic forget failed: ${result.stderr}"))
}
suspend fun prune(
repoPath: String,
password: String,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = ""
): Result<String> =
withContext(Dispatchers.IO) {
val env = buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass)
val result = runRestic(env, "prune")
if (result.exitCode == 0) Result.success(result.stdout)
else Result.failure(Exception("restic prune failed: ${result.stderr}"))
}
suspend fun check(
repoPath: String,
password: String,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = ""
): Result<String> =
withContext(Dispatchers.IO) {
val env = buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass)
val result = runRestic(env, "check")
if (result.exitCode == 0) Result.success(result.stdout)
else Result.failure(Exception("restic check failed: ${result.stderr}"))
}
suspend fun stats(
repoPath: String,
password: String,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = ""
): Result<String> =
withContext(Dispatchers.IO) {
val env = buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass)
val result = runRestic(env, "stats")
if (result.exitCode == 0) Result.success(result.stdout)
else Result.failure(Exception("restic stats failed: ${result.stderr}"))
}
// ── Internal helpers ───────────────────────────────
/**
* Build the full command list to run restic.
* On Android 10+ the app's data directory is noexec; we use the system
* linker as a trampoline to load the binary.
*/
fun buildCommandArgs(args: List<String>): List<String> {
val isAndroid = try {
Class.forName("android.os.Build")
true
} catch (_: ClassNotFoundException) {
false
}
if (!isAndroid) return listOf(binaryPath) + args
val linker = if (File("/system/bin/linker64").exists()) "/system/bin/linker64"
else "/system/bin/linker"
return listOf(linker, binaryPath) + args
}
/** Build environment for restic, with optional rclone backend config. */
fun buildFullEnv(
repoPath: String,
password: String,
backend: String = "local",
backendUrl: String = "",
backendUser: String = "",
backendPass: String = ""
): Map<String, String> {
val env = HashMap(System.getenv() ?: emptyMap())
env["RESTIC_REPOSITORY"] = buildRepoUrl(backend, repoPath, backendUrl)
env["RESTIC_PASSWORD"] = password
if (backend != "local") {
env.putAll(buildRcloneEnv(backend, backendUrl, backendUser, backendPass))
}
return env
}
/** Build the restic repository URL based on backend config. */
fun buildRepoUrl(backend: String, repoPath: String, backendUrl: String): String {
return if (backend == "local") repoPath
else "rclone:myremote:$repoPath"
}
/** Build RCLONE_CONFIG_* environment variables for restic's built-in rclone backend. */
private fun buildRcloneEnv(
backend: String,
url: String,
user: String,
pass: String
): Map<String, String> {
val env = HashMap<String, String>()
when (backend) {
"webdav" -> {
env["RCLONE_CONFIG_MYREMOTE_TYPE"] = "webdav"
env["RCLONE_CONFIG_MYREMOTE_URL"] = url.trimEnd('/')
env["RCLONE_CONFIG_MYREMOTE_VENDOR"] = "other"
if (user.isNotBlank()) env["RCLONE_CONFIG_MYREMOTE_USER"] = user
if (pass.isNotBlank()) env["RCLONE_CONFIG_MYREMOTE_PASS"] = pass
}
"smb" -> {
env["RCLONE_CONFIG_MYREMOTE_TYPE"] = "smb"
env["RCLONE_CONFIG_MYREMOTE_HOST"] = url
if (user.isNotBlank()) env["RCLONE_CONFIG_MYREMOTE_USER"] = user
if (pass.isNotBlank()) env["RCLONE_CONFIG_MYREMOTE_PASS"] = pass
}
}
return env
}
data class CommandResult(
val stdout: String,
val stderr: String,
val exitCode: Int
)
/** Run restic and capture full output. */
private fun runRestic(env: Map<String, String>, args: List<String>): CommandResult {
return try {
val cmdArgs = buildCommandArgs(args)
val pb = ProcessBuilder(cmdArgs)
pb.environment().putAll(env)
pb.redirectErrorStream(false)
val process = pb.start()
// Drain stderr in background to prevent pipe-buffer deadlock
val stderrText = StringBuilder()
val stderrThread = Thread({
try {
process.errorStream.bufferedReader().use { reader ->
var line: String?
while (reader.readLine().also { line = it } != null) {
stderrText.appendLine(line)
}
}
} catch (_: Exception) {}
}, "restic-stderr").apply { isDaemon = true; start() }
val stdout = process.inputStream.bufferedReader().use(BufferedReader::readText)
stderrThread.join(5000)
val exitCode = process.waitFor()
CommandResult(stdout.trim(), stderrText.toString().trim(), exitCode)
} catch (e: Exception) {
CommandResult("", e.message ?: "Unknown error", -1)
}
}
/** Run restic with single-string args (no spaces in args). */
private fun runRestic(env: Map<String, String>, vararg args: String): CommandResult {
return runRestic(env, args.toList())
}
/** Run restic, calling onLine for each stdout line (for streaming progress). */
private suspend fun runResticStreaming(
env: Map<String, String>,
args: List<String>,
onLine: suspend (String) -> Unit
): CommandResult = withContext(Dispatchers.IO) {
try {
val cmdArgs = buildCommandArgs(args)
val pb = ProcessBuilder(cmdArgs)
pb.environment().putAll(env)
pb.redirectErrorStream(false)
val process = pb.start()
val stdoutLines = StringBuilder()
val reader = BufferedReader(InputStreamReader(process.inputStream))
val stderrReader = BufferedReader(InputStreamReader(process.errorStream))
// Drain stderr in background
val stderrText = StringBuilder()
val stderrThread = Thread({
try { stderrReader.use { stderrText.append(it.readText()) } } catch (_: Exception) {}
}, "restic-stderr").apply { isDaemon = true; start() }
var line: String?
while (reader.readLine().also { line = it } != null) {
if (!coroutineContext.isActive) {
process.destroy()
break
}
val l = line!!
stdoutLines.appendLine(l)
onLine(l)
}
stderrThread.join(5000)
val exitCode = try { process.waitFor() } catch (_: Exception) { -1 }
reader.close()
CommandResult(stdoutLines.toString().trim(), stderrText.toString().trim(), exitCode)
} catch (e: Exception) {
CommandResult("", e.message ?: "Unknown error", -1)
}
}
/** Parse the JSON summary from the end of restic backup output. */
private fun parseBackupSummary(stdout: String): Result<BackupSummary> {
// The summary is the last JSON object in the output (after status lines)
val lines = stdout.lines()
for (i in lines.indices.reversed()) {
val line = lines[i].trim()
if (!line.startsWith("{")) continue
try {
val json = JSONObject(line)
if (json.optString("message_type", "") == "summary") {
return Result.success(BackupSummary(
snapshotId = json.optString("snapshot_id", ""),
filesNew = json.optInt("files_new", 0),
filesChanged = json.optInt("files_changed", 0),
filesUnmodified = json.optInt("files_unmodified", 0),
dirsNew = json.optInt("dirs_new", 0),
dirsChanged = json.optInt("dirs_changed", 0),
dirsUnmodified = json.optInt("dirs_unmodified", 0),
dataBlobs = json.optInt("data_blobs", 0),
treeBlobs = json.optInt("tree_blobs", 0),
dataAdded = json.optLong("data_added", 0),
totalFilesProcessed = json.optInt("total_files_processed", 0),
totalBytesProcessed = json.optLong("total_bytes_processed", 0),
totalDuration = json.optDouble("total_duration", 0.0)
))
}
} catch (_: Exception) { /* not a valid summary JSON, keep looking */ }
}
return Result.failure(Exception("No summary found in restic output"))
}
}

View File

@@ -0,0 +1,276 @@
package com.example.androidbackupgui.backup
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.coroutines.coroutineContext
/**
* Performs restore of backed-up apps using root shell.
* Mirrors the logic from backup_script's modules/restore.sh.
*/
object RestoreOperation {
data class RestoreProgress(
val current: Int,
val total: Int,
val packageName: String,
val stage: String, // "install", "data", "obb", "ssaid", "permissions", "done"
val message: String
)
data class RestoreResult(
val successCount: Int,
val failCount: Int,
val elapsedMs: Long
)
/**
* Restore apps from a backup directory.
* @param filterPkgs if non-null, only restore packages in this set
*/
suspend fun restoreApps(
backupDir: File,
userId: String = "0",
filterPkgs: Set<String>? = null,
onProgress: suspend (RestoreProgress) -> Unit = {}
): RestoreResult = withContext(Dispatchers.IO) {
val emit: suspend (RestoreProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } }
val startTime = System.currentTimeMillis()
// Read app list from backup
val appListFile = File(backupDir, "appList.txt")
val allPackages = if (appListFile.exists()) {
appListFile.readLines()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
} else {
// Fallback: scan subdirectories
backupDir.listFiles()
?.filter { it.isDirectory && File(it, "${it.name}.apk").exists() }
?.map { it.name }
?: emptyList()
}
val packages = if (filterPkgs != null) {
allPackages.filter { it in filterPkgs }
} else {
allPackages
}
var success = 0
var fail = 0
for ((index, pkg) in packages.withIndex()) {
if (!coroutineContext.isActive) break
val appBackupDir = File(backupDir, pkg)
if (!appBackupDir.exists()) {
fail++
continue
}
// 1. Install APK
emit(RestoreProgress(index + 1, packages.size, pkg, "install", "正在安裝 APK…"))
val installed = installApk(appBackupDir)
if (!installed) {
fail++
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "安裝失敗"))
continue
}
// 2. Stop the app before restoring data
RootShell.exec("am force-stop '${pkg.shellEscape()}'")
// 3. Restore data
emit(RestoreProgress(index + 1, packages.size, pkg, "data", "正在恢復數據…"))
restoreData(appBackupDir)
// 4. Restore OBB
emit(RestoreProgress(index + 1, packages.size, pkg, "obb", "正在恢復 OBB…"))
restoreObb(pkg, appBackupDir)
// 5. Restore SSAID
emit(RestoreProgress(index + 1, packages.size, pkg, "ssaid", "正在恢復 SSAID…"))
restoreSsaid(pkg, appBackupDir, userId)
// 6. Restore permissions
emit(RestoreProgress(index + 1, packages.size, pkg, "permissions", "正在恢復權限…"))
restorePermissions(pkg, appBackupDir)
// 7. Fix data ownership and SELinux
fixDataOwnership(pkg, userId)
success++
emit(RestoreProgress(index + 1, packages.size, pkg, "done", "完成"))
}
val elapsed = System.currentTimeMillis() - startTime
RestoreResult(success, fail, elapsed)
}
private fun installApk(appDir: File): Boolean {
// Find APK files
val apkFiles = appDir.listFiles()
?.filter { it.name.endsWith(".apk") }
?.sortedBy { it.name } // main APK first, splits after
?: return false
if (apkFiles.isEmpty()) return false
// Build install command for multiple APKs (split APK support)
val apkPaths = apkFiles.joinToString(" ") { "'${it.absolutePath.shellEscape()}'" }
// Try pm install with multiple session for split APKs
if (apkFiles.size > 1) {
val result = RootShell.exec("pm install-create -r -t 2>/dev/null")
val sessionId = result.output.lines()
.firstOrNull { it.contains("Success") }
?.substringAfter("[")
?.substringBefore("]")
if (sessionId != null) {
for ((i, apk) in apkFiles.withIndex()) {
val sessionName = if (i == 0) "base.apk" else "split_${i}.apk"
RootShell.exec("pm install-write '${sessionId.shellEscape()}' '$sessionName' '${apk.absolutePath.shellEscape()}'")
}
val commit = RootShell.exec("pm install-commit '${sessionId.shellEscape()}'")
return commit.isSuccess
}
}
// Single APK install
val result = RootShell.exec("pm install -r -t $apkPaths")
return result.isSuccess
}
private fun restoreData(appDir: File) {
// Find data archive
val dataFiles = appDir.listFiles()
?.filter { it.name.contains("_data.tar") }
?: 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")
}
}
}
}
/**
* Check that a tar archive contains no path traversal (..) entries,
* absolute paths, or symbolic links pointing outside the tree.
*/
private fun isArchiveSafe(archive: File): Boolean {
val listCmd = if (archive.name.endsWith(".zst")) {
"zstd -d -c '${archive.absolutePath.shellEscape()}' | tar tf - 2>/dev/null"
} else {
"tar tf '${archive.absolutePath.shellEscape()}' 2>/dev/null"
}
val result = RootShell.exec(listCmd)
if (!result.isSuccess) return false
return !result.output.lines().any { line ->
line.contains("..") ||
line.startsWith("/") ||
(line.contains(" -> ") && line.substringAfter(" -> ").startsWith("/"))
}
}
private fun restoreObb(packageName: String, appDir: File) {
val obbFiles = appDir.listFiles()
?.filter { it.name.contains("_obb.tar") }
?: return
for (archive in obbFiles) {
if (!isArchiveSafe(archive)) continue
val archivePath = archive.absolutePath.shellEscape()
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")
}
}
}
// Fix OBB permissions
RootShell.exec("chown -R 1023:1023 /storage/emulated/0/Android/obb/${packageName.shellEscape()}/ 2>/dev/null")
}
private fun restoreSsaid(packageName: String, appDir: File, userId: String) {
val ssaidFile = File(appDir, "ssaid.txt")
if (!ssaidFile.exists()) return
val ssaidLine = ssaidFile.readText().trim()
if (ssaidLine.isBlank()) return
val targetFile = "/data/system/users/${userId.shellEscape()}/settings_ssaid.xml"
val pkgEsc = packageName.shellEscape()
val ssaidEsc = ssaidLine.shellEscape()
// 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'"
)
}
private 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
val perm = line.substringBefore(":")
.trim()
.takeIf { it.startsWith("android.permission.") }
perm
}
val pkgEsc = packageName.shellEscape()
for (perm in perms) {
RootShell.exec("pm grant '$pkgEsc' '${perm.shellEscape()}' 2>/dev/null")
}
}
private fun fixDataOwnership(packageName: String, userId: String) {
val pkgEsc = packageName.shellEscape()
val uidEsc = userId.shellEscape()
val uidResult = RootShell.exec("dumpsys package '$pkgEsc' | grep 'userId=' | head -1")
val uid = uidResult.output
.substringAfter("userId=", "")
.substringBefore(" ")
.substringBefore(",")
.trim()
.toIntOrNull()
if (uid != null) {
RootShell.exec("chown -R $uid:$uid /data/data/$pkgEsc/ 2>/dev/null")
RootShell.exec("chown -R $uid:$uid /data/user_de/$uidEsc/$pkgEsc/ 2>/dev/null")
RootShell.exec("restorecon -R /data/data/$pkgEsc/ 2>/dev/null")
RootShell.exec("restorecon -R /data/user_de/$uidEsc/$pkgEsc/ 2>/dev/null")
}
}
}

View File

@@ -0,0 +1,77 @@
package com.example.androidbackupgui.backup
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
/**
* Backup and restore WiFi configuration.
* Mirrors backup_script WiFi backup/restore logic.
*/
object WifiManager {
// Possible WiFi config paths on different Android versions
private val WIFI_PATHS = listOf(
"/data/misc/apexdata/com.android.wifi/WifiConfigStore.xml",
"/data/misc/wifi/WifiConfigStore.xml",
"/data/misc/wifi/wpa_supplicant.conf",
"/data/vendor/wifi/wpa/wpa_supplicant.conf"
)
/**
* Find the active WiFi config file path. Public for use by BackupOperation.
*/
fun findWifiConfigPath(): String? {
for (path in WIFI_PATHS) {
val result = RootShell.exec("test -f '$path' && echo 'FOUND'")
if (result.output.contains("FOUND")) return path
}
return null
}
/**
* Backup WiFi configuration to a file.
* @return the backup file path, or null on failure.
*/
suspend fun backup(outputDir: File): File? = withContext(Dispatchers.IO) {
val wifiSource = findWifiConfigPath() ?: return@withContext null
val wifiDest = File(outputDir, "WifiConfigStore.xml")
val result = RootShell.exec("cp '$wifiSource' '${wifiDest.absolutePath.shellEscape()}'")
if (result.isSuccess) wifiDest else null
}
/**
* Restore WiFi configuration from a backup file.
* @return true on success.
*/
suspend fun restore(backupDir: File): Boolean = withContext(Dispatchers.IO) {
val backupFile = File(backupDir, "WifiConfigStore.xml")
if (!backupFile.exists()) return@withContext false
val backupPath = backupFile.absolutePath.shellEscape()
val wifiTarget = findWifiConfigPath()
if (wifiTarget == null) {
// 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 result = RootShell.exec("cp '$backupPath' '$fallback'")
if (!result.isSuccess) return@withContext false
RootShell.exec("chown system:wifi '$fallback'")
RootShell.exec("chmod 0660 '$fallback'")
} 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'")
}
// 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")
true
}
}

View File

@@ -0,0 +1,177 @@
package com.example.androidbackupgui.root
import kotlinx.coroutines.*
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.io.InputStream
/**
* Escape a string for safe use inside single-quoted shell strings.
* Replaces each ' with '\'' (end quote, escaped quote, restart quote).
*/
fun String.shellEscape(): String = this.replace("'", "'\\''")
/**
* Persistent root shell session via `su`.
* Manages a single su process and executes commands sequentially.
*/
object RootShell {
private var process: Process? = null
private var writer: OutputStreamWriter? = null
private var reader: BufferedReader? = null
private var errReader: BufferedReader? = null
private val lock = Any()
/** Result of a shell command execution. */
data class ShellResult(
val output: String,
val error: String,
val exitCode: Int
) {
val isSuccess get() = exitCode == 0
}
/** Ensure a root shell is open. Returns true if root is available. */
fun ensureSession(): Boolean = synchronized(lock) {
if (isAlive()) 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()
// Consume the initial (possibly empty) output
exec("echo ROOT_OK").output.contains("ROOT_OK")
} catch (_: Exception) {
false
}
}
fun isAlive(): Boolean = synchronized(lock) {
process?.isAlive == true
}
fun close() = synchronized(lock) {
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
}
/**
* Execute a command and return the output.
* Uses a sentinel delimiter to identify end of output.
*/
fun exec(command: String): ShellResult = synchronized(lock) {
if (!isAlive() && !ensureSession()) {
return ShellResult("", "No root access", -1)
}
val sentinel = "EXIT_${System.nanoTime()}"
val fullCmd = "$command; echo $sentinel \$?"
writer?.write("$fullCmd\n")
writer?.flush()
val output = StringBuilder()
val error = 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@exec ShellResult(
output = output.toString().trimEnd(),
error = error.toString().trimEnd(),
exitCode = code
)
}
output.appendLine(l)
}
ShellResult(output.toString().trimEnd(), error.toString().trimEnd(), -1)
}
/**
* Execute command in coroutine context on Dispatchers.IO.
*/
suspend fun execAsync(command: String): ShellResult =
withContext(Dispatchers.IO) { exec(command) }
/**
* Stream output line by line. Returns exit code.
*/
suspend fun execStreaming(
command: String,
onLine: suspend (String) -> Unit
): Int = withContext(Dispatchers.IO) {
val lines = mutableListOf<String>()
var exitCode = -1
synchronized(lock) {
if (!isAlive() && !ensureSession()) return@withContext -1
val sentinel = "EXIT_${System.nanoTime()}"
val fullCmd = "$command; echo $sentinel \$?"
writer?.write("$fullCmd\n")
writer?.flush()
var line: String?
while (reader?.readLine().also { line = it } != null) {
val l = line!!
if (l.startsWith(sentinel)) {
exitCode = l.removePrefix("$sentinel ").trim().toIntOrNull() ?: -1
break
}
lines.add(l)
}
}
for (l in lines) {
onLine(l)
}
exitCode
}
/**
* Execute a command via `su` and return the stdout as an InputStream
* for binary-safe streaming. Caller MUST close the stream and call
* waitForStreamResult() or destroy the returned process.
*/
class StreamProcess(
val process: Process,
val inputStream: InputStream,
private val command: String
) {
fun waitFor(): Int {
try { process.waitFor() } catch (_: Exception) {}
return process.exitValue()
}
fun destroy() {
try { process.destroy() } catch (_: Exception) {}
try { inputStream.close() } catch (_: Exception) {}
}
}
fun execBinary(command: String): StreamProcess? {
return try {
val p = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
// Drain stderr to prevent pipe deadlock
Thread({
try { p.errorStream.use { it.readBytes() } } catch (_: Exception) {}
}, "su-binary-stderr").apply { isDaemon = true }.start()
StreamProcess(p, p.inputStream, command)
} catch (_: Exception) {
null
}
}
}

View File

@@ -0,0 +1,224 @@
package com.example.androidbackupgui.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.LinearLayout
import android.widget.TextView
import com.google.android.material.card.MaterialCardView
import com.google.android.material.color.MaterialColors
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.androidbackupgui.R
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.ResticBinary
import com.example.androidbackupgui.backup.ResticWrapper
import com.example.androidbackupgui.backup.WifiManager
import com.example.androidbackupgui.databinding.FragmentBackupBinding
import kotlinx.coroutines.launch
import java.io.File
class BackupFragment : Fragment() {
private var _binding: FragmentBackupBinding? = null
private val binding get() = _binding!!
private var apps: List<AppInfo> = emptyList()
private var selectedApps = mutableSetOf<String>()
private lateinit var config: BackupConfig
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentBackupBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val configFile = File(requireContext().filesDir, "backup_settings.conf")
config = BackupConfig.fromFile(configFile)
binding.appList.layoutManager = LinearLayoutManager(requireContext())
binding.scanButton.setOnClickListener { scanApps() }
binding.backupButton.setOnClickListener { startBackup() }
}
private fun scanApps() {
binding.backupButton.isEnabled = false
setRunning(true)
binding.statusText.text = "正在掃描應用…"
viewLifecycleOwner.lifecycleScope.launch {
val thirdParty = AppScanner.scanThirdParty()
val system = AppScanner.scanSystem(config)
apps = thirdParty + system
selectedApps.clear()
selectedApps.addAll(apps.map { it.packageName })
binding.statusText.text = "共找到 ${apps.size} 個應用,全部已選中"
binding.backupButton.isEnabled = apps.isNotEmpty()
setRunning(false)
setupAppList()
}
}
private fun setupAppList() {
binding.appList.adapter = AppAdapter(apps, selectedApps) { pkg, checked ->
if (checked) selectedApps.add(pkg) else selectedApps.remove(pkg)
binding.statusText.text = "已選擇 ${selectedApps.size}/${apps.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
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 ->
binding.statusText.text =
"[${progress.current}/${progress.total}] ${progress.packageName}: ${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
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,
onProgress = { progress ->
if (progress.messageType == "status") {
binding.statusText.text = "restic: %.0f%% (%d/%d files)".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}")
}
}
setRunning(false)
binding.scanButton.isEnabled = true
}
}
private fun setRunning(running: Boolean) {
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
/** Simple RecyclerView adapter for app list with checkboxes. */
private class AppAdapter(
private val apps: List<AppInfo>,
private val selected: Set<String>,
private val onToggle: (String, Boolean) -> Unit
) : RecyclerView.Adapter<AppAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val checkbox: CheckBox = view.findViewById(R.id.checkbox)
val textView: TextView = view.findViewById(R.id.appName)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val ctx = parent.context
val card = MaterialCardView(ctx).apply {
layoutParams = ViewGroup.MarginLayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply { setMargins(0, 0, 0, 8) }
radius = 12f
cardElevation = 0f
strokeWidth = 0
setCardBackgroundColor(
MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurfaceContainer, 0)
)
}
val layout = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(16, 12, 16, 12)
}
val cb = CheckBox(ctx).apply { id = R.id.checkbox }
val tv = TextView(ctx).apply {
id = R.id.appName
setPadding(16, 0, 0, 0)
textSize = 15f
setTextColor(
MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, 0)
)
}
layout.addView(cb)
layout.addView(tv)
card.addView(layout)
return ViewHolder(card)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val app = apps[position]
holder.textView.text = app.packageName
holder.checkbox.isChecked = app.packageName in selected
holder.checkbox.setOnCheckedChangeListener { _, checked ->
onToggle(app.packageName, checked)
}
}
override fun getItemCount() = apps.size
}
}

View File

@@ -0,0 +1,286 @@
package com.example.androidbackupgui.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.example.androidbackupgui.R
import com.example.androidbackupgui.backup.BackupConfig
import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.ResticWrapper
import com.example.androidbackupgui.databinding.FragmentConfigBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
class ConfigFragment : Fragment() {
private var _binding: FragmentConfigBinding? = null
private val binding get() = _binding!!
private lateinit var config: BackupConfig
private lateinit var configFile: File
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentConfigBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
configFile = File(requireContext().filesDir, "backup_settings.conf")
config = BackupConfig.fromFile(configFile)
loadConfig()
binding.saveConfigButton.setOnClickListener { saveConfig() }
binding.resticBackendGroup.addOnButtonCheckedListener { _, _, _ -> updateBackendFieldVisibility() }
binding.initResticButton.setOnClickListener { initResticRepo() }
binding.resticStatsButton.setOnClickListener { showResticStats() }
binding.resticPruneButton.setOnClickListener { pruneResticSnapshots() }
}
private fun loadConfig() {
binding.backupModeSwitch.isChecked = config.backupMode == 1
binding.backupUserDataSwitch.isChecked = config.backupUserData == 1
binding.backupObbSwitch.isChecked = config.backupObbData == 1
binding.backupWifiSwitch.isChecked = config.backupWifi == 1
binding.ignoreRunningSwitch.isChecked = config.backgroundAppsIgnore == 1
binding.outputPathEdit.setText(config.outputPath)
binding.compressionEdit.setText(config.compressionMethod)
// Restic settings
binding.resticEnabledSwitch.isChecked = config.resticEnabled == 1
binding.resticRepoEdit.setText(config.resticRepo)
binding.resticPasswordEdit.setText(config.resticPassword)
binding.resticBackendUrlEdit.setText(config.resticBackendUrl)
binding.resticBackendUserEdit.setText(config.resticBackendUser)
binding.resticBackendPassEdit.setText(config.resticBackendPass)
// Restore backend selector
binding.resticBackendGroup.check(
when (config.resticBackend) {
"webdav" -> R.id.resticBackendWebdav
"smb" -> R.id.resticBackendSmb
else -> R.id.resticBackendLocal
}
)
updateBackendFieldVisibility()
updateComputedUrl()
refreshResticStatus()
}
private fun saveConfig() {
config.backupMode = if (binding.backupModeSwitch.isChecked) 1 else 0
config.backupUserData = if (binding.backupUserDataSwitch.isChecked) 1 else 0
config.backupObbData = if (binding.backupObbSwitch.isChecked) 1 else 0
config.backgroundAppsIgnore = if (binding.ignoreRunningSwitch.isChecked) 1 else 0
config.outputPath = binding.outputPathEdit.text?.toString() ?: ""
config.compressionMethod = binding.compressionEdit.text?.toString()?.ifEmpty { "zstd" } ?: "zstd"
config.backupWifi = if (binding.backupWifiSwitch.isChecked) 1 else 0
// Restic settings
config.resticEnabled = if (binding.resticEnabledSwitch.isChecked) 1 else 0
config.resticRepo = binding.resticRepoEdit.text?.toString()?.trim() ?: ""
config.resticPassword = binding.resticPasswordEdit.text?.toString() ?: ""
config.resticBackend = when (binding.resticBackendGroup.checkedButtonId) {
R.id.resticBackendWebdav -> "webdav"
R.id.resticBackendSmb -> "smb"
else -> "local"
}
config.resticBackendUrl = binding.resticBackendUrlEdit.text?.toString()?.trim() ?: ""
config.resticBackendUser = binding.resticBackendUserEdit.text?.toString()?.trim() ?: ""
config.resticBackendPass = binding.resticBackendPassEdit.text?.toString() ?: ""
viewLifecycleOwner.lifecycleScope.launch {
withContext(Dispatchers.IO) {
BackupConfig.toFile(config, configFile)
}
binding.configStatusText.text = "配置已保存到 ${configFile.absolutePath}"
}
}
private fun initResticRepo() {
val binaryPath = ResticBinary.prepare(requireContext())
if (binaryPath == null) {
binding.resticStatusText.text = "restic 二進制未就緒,請確保已安裝 restic 於 Termux 或 APK 內置版本可用"
return
}
ResticWrapper.binaryPath = binaryPath
val repo = binding.resticRepoEdit.text?.toString()?.trim() ?: ""
val password = binding.resticPasswordEdit.text?.toString() ?: ""
if (repo.isEmpty() || password.isEmpty()) {
binding.resticStatusText.text = "請填寫倉庫路徑和密碼"
return
}
binding.initResticButton.isEnabled = false
binding.resticStatusText.text = "正在初始化 restic 倉庫…"
viewLifecycleOwner.lifecycleScope.launch {
val result = ResticWrapper.init(repo, password,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass)
result.fold(
onSuccess = {
binding.resticStatusText.text = "倉庫初始化成功: $repo"
refreshResticStatus()
},
onFailure = { e -> binding.resticStatusText.text = "初始化失敗: ${e.message}" }
)
binding.initResticButton.isEnabled = true
}
}
/** Refresh the restic management buttons visibility based on repo state. */
private fun refreshResticStatus() {
if (config.resticEnabled != 1 || config.resticRepo.isBlank()) {
binding.initResticButton.visibility = View.GONE
binding.resticStatsButton.visibility = View.GONE
binding.resticPruneButton.visibility = View.GONE
return
}
val binaryPath = ResticBinary.prepare(requireContext())
if (binaryPath == null) {
binding.initResticButton.visibility = View.VISIBLE
binding.resticStatsButton.visibility = View.GONE
binding.resticPruneButton.visibility = View.GONE
binding.resticStatusText.text = "restic 二進制未就緒"
return
}
ResticWrapper.binaryPath = binaryPath
// Check if repo is initialized by listing snapshots
viewLifecycleOwner.lifecycleScope.launch {
val snapshotsResult = ResticWrapper.listSnapshots(config.resticRepo, config.resticPassword,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass)
if (snapshotsResult.isSuccess) {
val snapshots = snapshotsResult.getOrDefault(emptyList())
binding.initResticButton.visibility = View.GONE
binding.resticStatsButton.visibility = View.VISIBLE
binding.resticPruneButton.visibility = View.VISIBLE
binding.resticStatusText.text = "倉庫就緒,${snapshots.size} 個快照"
} else {
binding.initResticButton.visibility = View.VISIBLE
binding.resticStatsButton.visibility = View.GONE
binding.resticPruneButton.visibility = View.GONE
binding.resticStatusText.text = "倉庫未初始化或認證失敗"
}
}
}
private fun showResticStats() {
binding.resticStatsButton.isEnabled = false
binding.resticStatusText.text = "正在讀取統計…"
viewLifecycleOwner.lifecycleScope.launch {
val statsResult = ResticWrapper.stats(config.resticRepo, config.resticPassword,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass)
val snapshotsResult = ResticWrapper.listSnapshots(config.resticRepo, config.resticPassword,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass)
binding.resticStatsButton.isEnabled = true
val snapshotCount = snapshotsResult.getOrDefault(emptyList()).size
binding.resticStatusText.text = buildString {
appendLine("快照數: $snapshotCount")
if (statsResult.isSuccess) {
appendLine(statsResult.getOrDefault(""))
} else {
appendLine("統計讀取失敗: ${statsResult.exceptionOrNull()?.message}")
}
}
}
}
private fun pruneResticSnapshots() {
binding.resticPruneButton.isEnabled = false
binding.resticStatusText.text = "正在清理舊快照 (保留 7 天 / 4 週 / 3 月)…"
viewLifecycleOwner.lifecycleScope.launch {
val forgetResult = ResticWrapper.forget(
config.resticRepo, config.resticPassword,
keepDaily = 7, keepWeekly = 4, keepMonthly = 3,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass
)
if (forgetResult.isFailure) {
binding.resticStatusText.text = "forget 失敗: ${forgetResult.exceptionOrNull()?.message}"
binding.resticPruneButton.isEnabled = true
return@launch
}
binding.resticStatusText.text = "正在回收空間…"
val pruneResult = ResticWrapper.prune(config.resticRepo, config.resticPassword,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass)
binding.resticPruneButton.isEnabled = true
if (pruneResult.isSuccess) {
binding.resticStatusText.text = "清理完成!\n${pruneResult.getOrDefault("")}"
refreshResticStatus()
} else {
binding.resticStatusText.text = "prune 失敗: ${pruneResult.exceptionOrNull()?.message}"
}
}
}
/** Show/hide backend URL/user/pass fields based on selected backend. */
private fun updateBackendFieldVisibility() {
val backend = when (binding.resticBackendGroup.checkedButtonId) {
R.id.resticBackendWebdav -> "webdav"
R.id.resticBackendSmb -> "smb"
else -> "local"
}
val isRemote = backend != "local"
binding.resticBackendUrlLayout.visibility = if (isRemote) View.VISIBLE else View.GONE
binding.resticBackendUserLayout.visibility = if (isRemote) View.VISIBLE else View.GONE
binding.resticBackendPassLayout.visibility = if (isRemote) View.VISIBLE else View.GONE
// Update URL field hint
binding.resticBackendUrlLayout.hint = when (backend) {
"webdav" -> "WebDAV 地址 (https://host:port/path)"
"smb" -> "SMB 主機位址 (host 或 host:port)"
else -> ""
}
updateComputedUrl()
}
/** Show the computed restic repo URL. */
private fun updateComputedUrl() {
val backend = when (binding.resticBackendGroup.checkedButtonId) {
R.id.resticBackendWebdav -> "webdav"
R.id.resticBackendSmb -> "smb"
else -> "local"
}
val repo = binding.resticRepoEdit.text?.toString()?.trim() ?: ""
val url = binding.resticBackendUrlEdit.text?.toString()?.trim() ?: ""
val repoUrl = ResticWrapper.buildRepoUrl(backend, repo, url)
binding.resticComputedUrlText.text = if (repo.isNotEmpty())
"實際倉庫: $repoUrl" else ""
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,352 @@
package com.example.androidbackupgui.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.LinearLayout
import android.widget.TextView
import com.google.android.material.card.MaterialCardView
import com.google.android.material.color.MaterialColors
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.androidbackupgui.R
import com.example.androidbackupgui.backup.BackupConfig
import com.example.androidbackupgui.backup.RestoreOperation
import com.example.androidbackupgui.backup.ResticBinary
import com.example.androidbackupgui.backup.ResticWrapper
import com.example.androidbackupgui.backup.WifiManager
import com.example.androidbackupgui.databinding.FragmentRestoreBinding
import kotlinx.coroutines.launch
import java.io.File
class RestoreFragment : Fragment() {
private var _binding: FragmentRestoreBinding? = null
private val binding get() = _binding!!
private var backupDir: File? = null
private var packages: List<String> = emptyList()
private var selectedPackages = mutableSetOf<String>()
private var resticConfig: BackupConfig? = null
private var selectedSnapshot: ResticWrapper.ResticSnapshot? = null
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentRestoreBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.appList.layoutManager = LinearLayoutManager(requireContext())
// Load restic config
val configFile = File(requireContext().filesDir, "backup_settings.conf")
val config = BackupConfig.fromFile(configFile)
// Show restic button if enabled and binary available
if (config.resticEnabled == 1 && config.resticRepo.isNotBlank()) {
resticConfig = config
val binaryPath = ResticBinary.prepare(requireContext())
if (binaryPath != null) {
ResticWrapper.binaryPath = binaryPath
binding.selectResticButton.visibility = View.VISIBLE
}
}
binding.selectDirButton.setOnClickListener { selectBackupDir() }
binding.selectResticButton.setOnClickListener { selectResticSnapshot() }
binding.restoreButton.setOnClickListener { startRestore() }
}
private fun selectBackupDir() {
val defaultDir = File(requireContext().filesDir.absolutePath)
val backupDirs = defaultDir.listFiles()
?.filter { it.isDirectory && it.name.startsWith("Backup_") }
?: emptyList()
if (backupDirs.isNotEmpty()) {
backupDir = backupDirs.first()
selectedSnapshot = null
loadBackupDir(backupDirs.first())
} else {
binding.statusText.text = "未找到備份目錄,請確保 Backup_* 資料夾存在於 ${defaultDir.absolutePath}"
}
}
private fun loadBackupDir(dir: File) {
binding.backupDirText.text = dir.absolutePath
val appListFile = File(dir, "appList.txt")
packages = if (appListFile.exists()) {
appListFile.readLines()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
} else {
dir.listFiles()
?.filter { it.isDirectory }
?.map { it.name }
?: emptyList()
}
selectedPackages.clear()
selectedPackages.addAll(packages)
binding.statusText.text = "${packages.size} 個備份應用"
binding.restoreButton.isEnabled = packages.isNotEmpty()
setupAppList()
}
private fun selectResticSnapshot() {
val config = resticConfig ?: return
setRunning(true)
binding.statusText.text = "正在讀取 restic 快照列表…"
viewLifecycleOwner.lifecycleScope.launch {
val snapshotsResult = ResticWrapper.listSnapshots(
config.resticRepo, config.resticPassword,
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass
)
if (snapshotsResult.isFailure) {
binding.statusText.text = "讀取快照失敗: ${snapshotsResult.exceptionOrNull()?.message}"
setRunning(false)
return@launch
}
val snapshots = snapshotsResult.getOrThrow()
if (snapshots.isEmpty()) {
binding.statusText.text = "沒有可用的 restic 快照"
setRunning(false)
return@launch
}
// Switch to restic source
backupDir = null
selectedSnapshot = snapshots.first()
val backupPath = selectedSnapshot!!.paths.firstOrNull() ?: run {
binding.statusText.text = "快照中找不到備份路徑"
setRunning(false)
return@launch
}
// Read app list from the snapshot
val appListContent = readResticFile(config, selectedSnapshot!!.id, "$backupPath/appList.txt")
packages = if (appListContent != null) {
appListContent.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
} else {
emptyList()
}
if (packages.isEmpty()) {
binding.statusText.text = "無法從快照讀取應用列表"
setRunning(false)
return@launch
}
binding.backupDirText.text = "restic: ${selectedSnapshot!!.time.take(19)} (${snapshots.size} 個快照可用)"
selectedPackages.clear()
selectedPackages.addAll(packages)
binding.statusText.text = "restic 快照共 ${packages.size} 個應用,點擊恢復開始"
binding.restoreButton.isEnabled = true
setRunning(false)
setupAppList()
}
}
/** Read a single file from a restic snapshot using `restic dump`. */
private suspend fun readResticFile(
config: BackupConfig,
snapshotId: String,
filePath: String
): String? {
return try {
val env = ResticWrapper.buildFullEnv(
config.resticRepo,
config.resticPassword,
config.resticBackend,
config.resticBackendUrl,
config.resticBackendUser,
config.resticBackendPass
)
val cmd = ResticWrapper.buildCommandArgs(listOf("dump", snapshotId, filePath))
val process = ProcessBuilder(cmd)
.apply { environment().putAll(env) }
.redirectErrorStream(false)
.start()
// Drain stderr in background FIRST to prevent pipe-buffer deadlock
val stderrDrain = Thread({
try { process.errorStream.bufferedReader().use { while (it.readLine() != null) {} } } catch (_: Exception) {}
}, "restic-dump-stderr").apply { isDaemon = true; start() }
val stdout = process.inputStream.bufferedReader().use { it.readText() }
stderrDrain.join(5000)
process.waitFor()
if (process.exitValue() == 0) stdout else null
} catch (_: Exception) {
null
}
}
private fun setupAppList() {
binding.appList.adapter = PackageAdapter(packages, selectedPackages) { pkg, checked ->
if (checked) selectedPackages.add(pkg) else selectedPackages.remove(pkg)
binding.statusText.text = "已選擇 ${selectedPackages.size}/${packages.size} 個應用"
}
}
private fun startRestore() {
val toRestore = packages.filter { it in selectedPackages }
if (toRestore.isEmpty()) return
setRunning(true)
binding.restoreButton.isEnabled = false
binding.selectDirButton.isEnabled = false
viewLifecycleOwner.lifecycleScope.launch {
val result = if (selectedSnapshot != null && resticConfig != null) {
// Restic restore
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,
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 ->
binding.statusText.text =
"[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
}
)
// Cleanup staging
try { staging.deleteRecursively() } catch (_: Exception) {}
r
} else {
// Local restore
val dir = backupDir ?: return@launch
val r = RestoreOperation.restoreApps(
backupDir = dir,
filterPkgs = selectedPackages,
onProgress = { progress ->
binding.statusText.text =
"[${progress.current}/${progress.total}] ${progress.packageName}: ${progress.message}"
}
)
// Also restore WiFi if backup exists locally
WifiManager.restore(dir)
r
}
binding.statusText.text = buildString {
appendLine("恢復完成!")
appendLine("成功: ${result.successCount} 失敗: ${result.failCount}")
appendLine("耗時: ${result.elapsedMs / 1000}")
appendLine("如有 SSAID請立即重啟設備後再開啟應用")
}
setRunning(false)
binding.selectDirButton.isEnabled = true
}
}
private fun setRunning(running: Boolean) {
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private class PackageAdapter(
private val packages: List<String>,
private val selected: Set<String>,
private val onToggle: (String, Boolean) -> Unit
) : RecyclerView.Adapter<PackageAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val checkbox: CheckBox = view.findViewById(R.id.checkbox)
val textView: TextView = view.findViewById(R.id.appName)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val ctx = parent.context
val card = MaterialCardView(ctx).apply {
layoutParams = ViewGroup.MarginLayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply { setMargins(0, 0, 0, 8) }
radius = 12f
cardElevation = 0f
strokeWidth = 0
setCardBackgroundColor(
MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurfaceContainer, 0)
)
}
val layout = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(16, 12, 16, 12)
}
val cb = CheckBox(ctx).apply { id = R.id.checkbox }
val tv = TextView(ctx).apply {
id = R.id.appName
setPadding(16, 0, 0, 0)
textSize = 15f
setTextColor(
MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, 0)
)
}
layout.addView(cb)
layout.addView(tv)
card.addView(layout)
return ViewHolder(card)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val pkg = packages[position]
holder.textView.text = pkg
holder.checkbox.isChecked = pkg in selected
holder.checkbox.setOnCheckedChangeListener { _, checked ->
onToggle(pkg, checked)
}
}
override fun getItemCount() = packages.size
}
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M19,9h-4V3H9v6H5l7,7 7,-7zM5,18v2h14v-2H5z" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94 0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61l-2.01,-1.58zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6 3.6,1.62 3.6,3.6 -1.62,3.6 -3.6,3.6z" />
</vector>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1565C0"
android:pathData="M54,27 C39.1,27 27,39.1 27,54 C27,68.9 39.1,81 54,81 C68.9,81 81,68.9 81,54 C81,39.1 68.9,27 54,27 Z M54,36 L54,45 L63,45 L63,63 L45,63 L45,45 L54,45 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M50,45 L58,45 L58,48 L50,48 Z M50,52 L58,52 L58,55 L50,55 Z M50,59 L58,59 L58,62 L50,62 Z" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M9,16h6v-6h4l-7,-7 -7,7h4zM5,18h14v2H5z" />
</vector>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?attr/colorSurface">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/topAppBar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorSurface"
app:title="@string/app_name"
app:titleCentered="true"
app:titleTextColor="?attr/colorOnSurface"
style="@style/Widget.Material3.Toolbar" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottomNav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurfaceContainer"
app:menu="@menu/bottom_nav"
app:labelVisibilityMode="labeled" />
</LinearLayout>

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
android:background="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="應用備份"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.button.MaterialButton
android:id="@+id/scanButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="掃描應用"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:indeterminate="true"
android:visibility="gone"
app:indicatorColor="?attr/colorPrimary"
app:trackColor="?attr/colorSurfaceVariant" />
<TextView
android:id="@+id/statusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="點擊掃描以載入應用列表"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/appList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="12dp"
android:clipToPadding="false"
android:paddingBottom="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/backupButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:enabled="false"
android:text="開始備份選中應用"
style="@style/Widget.Material3.Button" />
</LinearLayout>

View File

@@ -0,0 +1,347 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:clipToPadding="false"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- ═══════ 備份選項 ═══════ -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardElevation="0dp"
app:cardBackgroundColor="?attr/colorSurfaceContainer"
app:strokeWidth="0dp"
app:contentPadding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="備份選項"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:textColor="?attr/colorPrimary" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/backupModeSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="備份數據+安裝包 (關閉則僅備份安裝包)" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/backupUserDataSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="備份使用者數據" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/backupObbSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="備份 OBB 外部數據包" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/backupWifiSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="備份 WiFi 設定" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/ignoreRunningSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="忽略運行中的應用" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- ═══════ 輸出路徑 ═══════ -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardElevation="0dp"
app:cardBackgroundColor="?attr/colorSurfaceContainer"
app:strokeWidth="0dp"
app:contentPadding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="輸出路徑"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:textColor="?attr/colorPrimary" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="輸出路徑 (留空使用默認)"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/outputPathEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="壓縮算法 (zstd / tar)"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/compressionEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="zstd" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- ═══════ Restic 雲端備份 ═══════ -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardElevation="0dp"
app:cardBackgroundColor="?attr/colorSurfaceContainer"
app:strokeWidth="0dp"
app:contentPadding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Restic 雲端備份"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:textColor="?attr/colorPrimary" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/resticEnabledSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="啟用 restic 增量去重 (需安裝 restic 二進制)" />
<!-- Backend selector -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="儲存位置"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/resticBackendGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/resticBackendLocal"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="本機"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/resticBackendWebdav"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="WebDAV"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/resticBackendSmb"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="SMB"
style="@style/Widget.Material3.Button.TonalButton" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<!-- Backend URL (WebDAV/SMB only) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/resticBackendUrlLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="WebDAV 地址 (https://host:port/path)"
android:visibility="gone"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/resticBackendUrlEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Backend user (WebDAV/SMB only) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/resticBackendUserLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="用戶名"
android:visibility="gone"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/resticBackendUserEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Backend password (WebDAV/SMB only) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/resticBackendPassLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="密碼"
android:visibility="gone"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/resticBackendPassEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Repo path (always shown) -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="倉庫路徑 (本機: /sdcard/restic-repo / 雲端: 目錄名如 android-backups)"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/resticRepoEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Repo encryption password (always shown) -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="倉庫加密密碼 (請妥善保管,遺失即無法恢復)"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/resticPasswordEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Computed repo URL -->
<TextView
android:id="@+id/resticComputedUrlText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<!-- Action buttons -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/initResticButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="初始化"
style="@style/Widget.Material3.Button.TextButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/resticStatsButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="統計"
android:visibility="gone"
style="@style/Widget.Material3.Button.TextButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/resticPruneButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="清理"
android:visibility="gone"
style="@style/Widget.Material3.Button.TextButton" />
</LinearLayout>
<TextView
android:id="@+id/resticStatusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.button.MaterialButton
android:id="@+id/saveConfigButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="保存配置"
style="@style/Widget.Material3.Button" />
<TextView
android:id="@+id/configStatusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
android:background="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="應用恢復"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.button.MaterialButton
android:id="@+id/selectDirButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="本地"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/selectResticButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Restic"
android:visibility="gone"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
<TextView
android:id="@+id/backupDirText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:ellipsize="middle"
android:singleLine="true"
android:text="未選擇備份目錄"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:indeterminate="true"
android:visibility="gone"
app:indicatorColor="?attr/colorPrimary"
app:trackColor="?attr/colorSurfaceVariant" />
<TextView
android:id="@+id/statusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="請先選擇備份資料夾"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/appList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="12dp"
android:clipToPadding="false"
android:paddingBottom="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/restoreButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:enabled="false"
android:text="開始恢復選中應用"
style="@style/Widget.Material3.Button" />
</LinearLayout>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/nav_backup"
android:icon="@drawable/ic_backup"
android:title="備份" />
<item
android:id="@+id/nav_restore"
android:icon="@drawable/ic_restore"
android:title="恢復" />
<item
android:id="@+id/nav_config"
android:icon="@drawable/ic_config"
android:title="配置" />
</menu>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- MD3 Primary (dark) -->
<color name="primary">#9ECAFF</color>
<color name="onPrimary">#003258</color>
<color name="primaryContainer">#00497D</color>
<color name="onPrimaryContainer">#D1E4FF</color>
<!-- MD3 Secondary (dark) -->
<color name="secondary">#FFB870</color>
<color name="onSecondary">#4A2800</color>
<color name="secondaryContainer">#6A3C00</color>
<color name="onSecondaryContainer">#FFDCB5</color>
<!-- MD3 Tertiary (dark) -->
<color name="tertiary">#8CD4C4</color>
<color name="onTertiary">#00382F</color>
<color name="tertiaryContainer">#005045</color>
<color name="onTertiaryContainer">#A7F0DE</color>
<!-- MD3 Error (dark) -->
<color name="error">#FFB4AB</color>
<color name="onError">#690005</color>
<color name="errorContainer">#93000A</color>
<color name="onErrorContainer">#FFDAD6</color>
<!-- MD3 Surface / Background (dark) -->
<color name="background">#1A1C1E</color>
<color name="onBackground">#E2E2E6</color>
<color name="surface">#1A1C1E</color>
<color name="onSurface">#E2E2E6</color>
<color name="surfaceVariant">#43474E</color>
<color name="onSurfaceVariant">#C3C6CF</color>
<color name="inverseSurface">#E2E2E6</color>
<color name="inverseOnSurface">#2F3033</color>
<!-- MD3 Outline (dark) -->
<color name="outline">#8D9199</color>
<color name="outlineVariant">#43474E</color>
<!-- Surface container hierarchy (dark) -->
<color name="surfaceContainerLowest">#0E1114</color>
<color name="surfaceContainerLow">#1A1C1E</color>
<color name="surfaceContainer">#1E2023</color>
<color name="surfaceContainerHigh">#292A2E</color>
<color name="surfaceContainerHighest">#333539</color>
<!-- Legacy console colors -->
<color name="consoleBg">#102027</color>
<color name="consoleText">#ECEFF1</color>
</resources>

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Primary -->
<item name="colorPrimary">@color/primary</item>
<item name="colorOnPrimary">@color/onPrimary</item>
<item name="colorPrimaryContainer">@color/primaryContainer</item>
<item name="colorOnPrimaryContainer">@color/onPrimaryContainer</item>
<!-- Secondary -->
<item name="colorSecondary">@color/secondary</item>
<item name="colorOnSecondary">@color/onSecondary</item>
<item name="colorSecondaryContainer">@color/secondaryContainer</item>
<item name="colorOnSecondaryContainer">@color/onSecondaryContainer</item>
<!-- Tertiary -->
<item name="colorTertiary">@color/tertiary</item>
<item name="colorOnTertiary">@color/onTertiary</item>
<item name="colorTertiaryContainer">@color/tertiaryContainer</item>
<item name="colorOnTertiaryContainer">@color/onTertiaryContainer</item>
<!-- Error -->
<item name="colorError">@color/error</item>
<item name="colorOnError">@color/onError</item>
<item name="colorErrorContainer">@color/errorContainer</item>
<item name="colorOnErrorContainer">@color/onErrorContainer</item>
<!-- Surface / Background -->
<item name="android:colorBackground">@color/background</item>
<item name="colorOnBackground">@color/onBackground</item>
<item name="colorSurface">@color/surface</item>
<item name="colorOnSurface">@color/onSurface</item>
<item name="colorSurfaceVariant">@color/surfaceVariant</item>
<item name="colorOnSurfaceVariant">@color/onSurfaceVariant</item>
<item name="colorSurfaceInverse">@color/inverseSurface</item>
<item name="colorOnSurfaceInverse">@color/inverseOnSurface</item>
<!-- Outline -->
<item name="colorOutline">@color/outline</item>
<item name="colorOutlineVariant">@color/outlineVariant</item>
<!-- Surface container hierarchy -->
<item name="colorSurfaceContainerLowest">@color/surfaceContainerLowest</item>
<item name="colorSurfaceContainerLow">@color/surfaceContainerLow</item>
<item name="colorSurfaceContainer">@color/surfaceContainer</item>
<item name="colorSurfaceContainerHigh">@color/surfaceContainerHigh</item>
<item name="colorSurfaceContainerHighest">@color/surfaceContainerHighest</item>
<!-- Status bar — dark theme -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- MD3 Primary — deep blue -->
<color name="primary">#1565C0</color>
<color name="onPrimary">#FFFFFF</color>
<color name="primaryContainer">#D1E4FF</color>
<color name="onPrimaryContainer">#001D36</color>
<!-- MD3 Secondary — amber accent -->
<color name="secondary">#FF8F00</color>
<color name="onSecondary">#FFFFFF</color>
<color name="secondaryContainer">#FFDCB5</color>
<color name="onSecondaryContainer">#2D1800</color>
<!-- MD3 Tertiary — teal -->
<color name="tertiary">#00796B</color>
<color name="onTertiary">#FFFFFF</color>
<color name="tertiaryContainer">#A7F0DE</color>
<color name="onTertiaryContainer">#001F19</color>
<!-- MD3 Error -->
<color name="error">#BA1A1A</color>
<color name="onError">#FFFFFF</color>
<color name="errorContainer">#FFDAD6</color>
<color name="onErrorContainer">#410002</color>
<!-- MD3 Surface / Background (light) -->
<color name="background">#FDFCFF</color>
<color name="onBackground">#1A1C1E</color>
<color name="surface">#FDFCFF</color>
<color name="onSurface">#1A1C1E</color>
<color name="surfaceVariant">#DFE2EB</color>
<color name="onSurfaceVariant">#43474E</color>
<color name="inverseSurface">#2F3033</color>
<color name="inverseOnSurface">#F1F0F4</color>
<!-- MD3 Outline -->
<color name="outline">#73777F</color>
<color name="outlineVariant">#C3C6CF</color>
<!-- Surface container hierarchy (light) -->
<color name="surfaceContainerLowest">#FFFFFF</color>
<color name="surfaceContainerLow">#F7F9FC</color>
<color name="surfaceContainer">#F1F3F7</color>
<color name="surfaceContainerHigh">#EBEDF1</color>
<color name="surfaceContainerHighest">#E6E8EB</color>
<!-- Legacy console colors -->
<color name="consoleBg">#263238</color>
<color name="consoleText">#ECEFF1</color>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="checkbox" type="id" />
<item name="appName" type="id" />
</resources>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Android 备份工具</string>
<string name="script_label">选择脚本</string>
<string name="param_label">脚本参数</string>
<string name="param_hint">输入命令行参数</string>
<string name="run_button">▶ 执行</string>
<string name="stop_button">■ 停止</string>
<string name="clear_button">清空输出</string>
<string name="root_toggle">Root 权限</string>
<string name="status_ready">就绪</string>
<string name="status_running">执行中…</string>
<string name="status_done">完成 (退出码: %d)</string>
<string name="status_error">执行失败: %s</string>
<string name="status_cancelled">已取消</string>
</resources>

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Primary -->
<item name="colorPrimary">@color/primary</item>
<item name="colorOnPrimary">@color/onPrimary</item>
<item name="colorPrimaryContainer">@color/primaryContainer</item>
<item name="colorOnPrimaryContainer">@color/onPrimaryContainer</item>
<item name="colorPrimaryInverse">@color/inverseSurface</item>
<!-- Secondary -->
<item name="colorSecondary">@color/secondary</item>
<item name="colorOnSecondary">@color/onSecondary</item>
<item name="colorSecondaryContainer">@color/secondaryContainer</item>
<item name="colorOnSecondaryContainer">@color/onSecondaryContainer</item>
<!-- Tertiary -->
<item name="colorTertiary">@color/tertiary</item>
<item name="colorOnTertiary">@color/onTertiary</item>
<item name="colorTertiaryContainer">@color/tertiaryContainer</item>
<item name="colorOnTertiaryContainer">@color/onTertiaryContainer</item>
<!-- Error -->
<item name="colorError">@color/error</item>
<item name="colorOnError">@color/onError</item>
<item name="colorErrorContainer">@color/errorContainer</item>
<item name="colorOnErrorContainer">@color/onErrorContainer</item>
<!-- Surface / Background -->
<item name="android:colorBackground">@color/background</item>
<item name="colorOnBackground">@color/onBackground</item>
<item name="colorSurface">@color/surface</item>
<item name="colorOnSurface">@color/onSurface</item>
<item name="colorSurfaceVariant">@color/surfaceVariant</item>
<item name="colorOnSurfaceVariant">@color/onSurfaceVariant</item>
<item name="colorSurfaceInverse">@color/inverseSurface</item>
<item name="colorOnSurfaceInverse">@color/inverseOnSurface</item>
<!-- Outline -->
<item name="colorOutline">@color/outline</item>
<item name="colorOutlineVariant">@color/outlineVariant</item>
<!-- Surface container hierarchy -->
<item name="colorSurfaceContainerLowest">@color/surfaceContainerLowest</item>
<item name="colorSurfaceContainerLow">@color/surfaceContainerLow</item>
<item name="colorSurfaceContainer">@color/surfaceContainer</item>
<item name="colorSurfaceContainerHigh">@color/surfaceContainerHigh</item>
<item name="colorSurfaceContainerHighest">@color/surfaceContainerHighest</item>
<!-- Status bar -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>

3
gradle.properties Normal file
View File

@@ -0,0 +1,3 @@
android.useAndroidX=true
android.enableJetifier=false
org.gradle.jvmargs=-Xmx2048m

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

176
gradlew vendored Executable file
View File

@@ -0,0 +1,176 @@
#!/usr/bin/env sh
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then
DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS"
fi
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"

View File

@@ -1,2 +0,0 @@
# 该目录用于存放将要在 Android 端执行的 shell 脚本
# 请将 backup_script/tools.sh 复制到此目录下,并确保脚本有可执行权限