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:
15
.github/copilot-instructions.md
vendored
15
.github/copilot-instructions.md
vendored
@@ -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 图形化操作界面,支持本地运行脚本、参数配置、结果展示。
|
|
||||||
|
|
||||||
## 进度说明
|
|
||||||
- 已完成项目结构搭建与主要文件生成。
|
|
||||||
- 下一步将完善脚本调用、参数配置与界面交互细节。
|
|
||||||
@@ -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}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -2,20 +2,31 @@ apply plugin: 'com.android.application'
|
|||||||
apply plugin: 'kotlin-android'
|
apply plugin: 'kotlin-android'
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion 34
|
namespace "com.example.androidbackupgui"
|
||||||
|
compileSdk 34
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.example.androidbackupgui"
|
applicationId "com.example.androidbackupgui"
|
||||||
minSdkVersion 24
|
minSdk 24
|
||||||
targetSdkVersion 34
|
targetSdk 34
|
||||||
versionCode 1
|
versionCode 1
|
||||||
versionName "1.0"
|
versionName "1.0"
|
||||||
}
|
}
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding true
|
||||||
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '17'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -24,4 +35,9 @@ dependencies {
|
|||||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
implementation 'com.google.android.material:material:1.11.0'
|
implementation 'com.google.android.material:material:1.11.0'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
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
1
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
package="com.example.androidbackupgui">
|
|
||||||
|
<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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.AppCompat.Light.DarkActionBar">
|
android:theme="@style/AppTheme">
|
||||||
<activity android:name=".MainActivity">
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
BIN
app/src/main/assets/restic/arm64-v8a/restic
Executable file
BIN
app/src/main/assets/restic/arm64-v8a/restic
Executable file
Binary file not shown.
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}\"")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
177
app/src/main/java/com/example/androidbackupgui/root/RootShell.kt
Normal file
177
app/src/main/java/com/example/androidbackupgui/root/RootShell.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/src/main/res/drawable/ic_backup.xml
Normal file
11
app/src/main/res/drawable/ic_backup.xml
Normal 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>
|
||||||
11
app/src/main/res/drawable/ic_config.xml
Normal file
11
app/src/main/res/drawable/ic_config.xml
Normal 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>
|
||||||
13
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
13
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||||
11
app/src/main/res/drawable/ic_restore.xml
Normal file
11
app/src/main/res/drawable/ic_restore.xml
Normal 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>
|
||||||
33
app/src/main/res/layout/activity_main.xml
Normal file
33
app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||||
69
app/src/main/res/layout/fragment_backup.xml
Normal file
69
app/src/main/res/layout/fragment_backup.xml
Normal 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>
|
||||||
347
app/src/main/res/layout/fragment_config.xml
Normal file
347
app/src/main/res/layout/fragment_config.xml
Normal 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>
|
||||||
89
app/src/main/res/layout/fragment_restore.xml
Normal file
89
app/src/main/res/layout/fragment_restore.xml
Normal 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>
|
||||||
15
app/src/main/res/menu/bottom_nav.xml
Normal file
15
app/src/main/res/menu/bottom_nav.xml
Normal 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>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||||
51
app/src/main/res/values-night/colors.xml
Normal file
51
app/src/main/res/values-night/colors.xml
Normal 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>
|
||||||
54
app/src/main/res/values-night/themes.xml
Normal file
54
app/src/main/res/values-night/themes.xml
Normal 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>
|
||||||
51
app/src/main/res/values/colors.xml
Normal file
51
app/src/main/res/values/colors.xml
Normal 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>
|
||||||
5
app/src/main/res/values/ids.xml
Normal file
5
app/src/main/res/values/ids.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<item name="checkbox" type="id" />
|
||||||
|
<item name="appName" type="id" />
|
||||||
|
</resources>
|
||||||
16
app/src/main/res/values/strings.xml
Normal file
16
app/src/main/res/values/strings.xml
Normal 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>
|
||||||
55
app/src/main/res/values/themes.xml
Normal file
55
app/src/main/res/values/themes.xml
Normal 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
3
gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=false
|
||||||
|
org.gradle.jvmargs=-Xmx2048m
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
176
gradlew
vendored
Executable 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" "$@"
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# 该目录用于存放将要在 Android 端执行的 shell 脚本
|
|
||||||
# 请将 backup_script/tools.sh 复制到此目录下,并确保脚本有可执行权限
|
|
||||||
Reference in New Issue
Block a user