diff --git a/app/src/main/java/com/example/androidbackupgui/MainActivity.kt b/app/src/main/java/com/example/androidbackupgui/MainActivity.kt index 9510b17..e76c881 100644 --- a/app/src/main/java/com/example/androidbackupgui/MainActivity.kt +++ b/app/src/main/java/com/example/androidbackupgui/MainActivity.kt @@ -13,6 +13,8 @@ import androidx.viewpager2.widget.ViewPager2 import com.example.androidbackupgui.databinding.ActivityMainBinding import com.example.androidbackupgui.root.RootShell import com.example.androidbackupgui.backup.LogUtil +import com.example.androidbackupgui.backup.ResticBinary +import com.example.androidbackupgui.backup.ResticWrapper import com.example.androidbackupgui.ui.BackupFragment import com.example.androidbackupgui.ui.ConfigFragment import com.example.androidbackupgui.ui.RestoreFragment @@ -39,6 +41,9 @@ class MainActivity : AppCompatActivity() { // Configure libsu with global mount namespace support RootShell.configure() + // Initialize restic binary path + ResticBinary.prepare(this)?.let { ResticWrapper.binaryPath = it } + // Request root access on startup lifecycleScope.launch { withContext(Dispatchers.IO) { diff --git a/app/src/main/java/com/example/androidbackupgui/backup/ResticMaintenance.kt b/app/src/main/java/com/example/androidbackupgui/backup/ResticMaintenance.kt index 2fc8b35..963ab0c 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/ResticMaintenance.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/ResticMaintenance.kt @@ -61,6 +61,36 @@ class ResticMaintenance( } } + // ── Unlock ────────────────────────────────────────── + + suspend fun unlock( + repoPath: String, + password: String, + backend: String = "local", + backendUrl: String = "", + backendUser: String = "", + backendPass: String = "", + backendShare: String = "", + ): AppResult = + withContext(Dispatchers.IO) { + if (backend == "local") { + val env = envResolver.buildLocalEnv(repoPath, password, cacheDir) + val result = runner.runRestic(env, "unlock") + if (result.exitCode == 0) AppResult.Success(result.stdout) + else err(AppError.Restic("restic unlock 失败", result.exitCode, result.stderr)) + } else { + bridgeRunner.withBridge( + backend, backendUrl, backendUser, backendPass, backendShare, + backendDomain, repoPath, File(cacheDir) + ) { bridgeUrl, authToken -> + val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken) + val result = runner.runRestic(env, "unlock") + if (result.exitCode == 0) AppResult.Success(result.stdout) + else err(AppError.Restic("restic unlock 失败", result.exitCode, result.stderr)) + } + } + } + // ── Check ────────────────────────────────────────── suspend fun check( diff --git a/app/src/main/java/com/example/androidbackupgui/backup/ResticWrapper.kt b/app/src/main/java/com/example/androidbackupgui/backup/ResticWrapper.kt index 2378f71..81964db 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/ResticWrapper.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/ResticWrapper.kt @@ -359,6 +359,20 @@ object ResticWrapper { backend, backendUrl, backendUser, backendPass, backendShare ) + suspend fun unlock( + repoPath: String, + password: String, + backend: String = "local", + backendUrl: String = "", + backendUser: String = "", + backendPass: String = "", + backendShare: String = "", + ): AppResult = + maintenance.unlock( + repoPath, password, + backend, backendUrl, backendUser, backendPass, backendShare, + ) + // ── Public URL helper ────────────────────────────── /** Build a display-friendly repository URL for UI. */ diff --git a/app/src/main/java/com/example/androidbackupgui/ui/BackupFragment.kt b/app/src/main/java/com/example/androidbackupgui/ui/BackupFragment.kt index 41b1b7a..380ab2c 100644 --- a/app/src/main/java/com/example/androidbackupgui/ui/BackupFragment.kt +++ b/app/src/main/java/com/example/androidbackupgui/ui/BackupFragment.kt @@ -97,8 +97,8 @@ class BackupFragment : Fragment() { val names = userList.map { (id, name) -> "$name (ID: $id)" } val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names) adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - binding.userSelector.adapter = adapter - binding.userSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + binding.backupUserSelector.adapter = adapter + binding.backupUserSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { selectedUserId = userList.getOrNull(position)?.first ?: 0 } @@ -401,7 +401,7 @@ class BackupFragment : Fragment() { private fun updateOutputPathDisplay() { val path = config.outputPath.ifEmpty { requireContext().filesDir.absolutePath } - binding.outputPathLabel.text = path + binding.outputPathLabel.text = "目录: $path" } diff --git a/app/src/main/java/com/example/androidbackupgui/ui/ConfigFragment.kt b/app/src/main/java/com/example/androidbackupgui/ui/ConfigFragment.kt index 9162e8c..3270056 100644 --- a/app/src/main/java/com/example/androidbackupgui/ui/ConfigFragment.kt +++ b/app/src/main/java/com/example/androidbackupgui/ui/ConfigFragment.kt @@ -67,6 +67,9 @@ class ConfigFragment : Fragment() { binding.initResticButton.setOnClickListener { initResticRepo() } binding.resticStatsButton.setOnClickListener { showResticStats() } binding.resticPruneButton.setOnClickListener { pruneResticSnapshots() } + binding.resticUnlockButton.setOnClickListener { + vm.unlockResticRepo(readResticForm()) + } // Initial async status check refreshResticStatus() @@ -140,6 +143,8 @@ class ConfigFragment : Fragment() { binding.resticStatsButton.visibility = if (statsButtonVisible) View.VISIBLE else View.GONE binding.resticPruneButton.isEnabled = pruneButtonEnabled binding.resticPruneButton.visibility = if (pruneButtonVisible) View.VISIBLE else View.GONE + binding.resticUnlockButton.isEnabled = unlockButtonEnabled + binding.resticUnlockButton.visibility = if (unlockButtonVisible) View.VISIBLE else View.GONE } } diff --git a/app/src/main/java/com/example/androidbackupgui/ui/ConfigViewModel.kt b/app/src/main/java/com/example/androidbackupgui/ui/ConfigViewModel.kt index 7d7a851..34aae26 100644 --- a/app/src/main/java/com/example/androidbackupgui/ui/ConfigViewModel.kt +++ b/app/src/main/java/com/example/androidbackupgui/ui/ConfigViewModel.kt @@ -44,7 +44,9 @@ data class ResticStatus( val statsButtonVisible: Boolean = false, val statsButtonEnabled: Boolean = true, val pruneButtonVisible: Boolean = false, - val pruneButtonEnabled: Boolean = true + val pruneButtonEnabled: Boolean = true, + val unlockButtonVisible: Boolean = false, + val unlockButtonEnabled: Boolean = true ) /** Restic credential/form snapshot passed from Fragment on every user interaction. */ @@ -245,16 +247,39 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application) _uiState.update { it.copy(resticStatus = ResticStatus( message = "仓库就绪,${snapshots.size} 个快照", snapshotCount = snapshots.size, - initButtonVisible = false, statsButtonVisible = true, pruneButtonVisible = true + initButtonVisible = false, statsButtonVisible = true, pruneButtonVisible = true, + unlockButtonVisible = true ))} } else { + val errMsg = snapshotsResult.errorOrNull()?.message ?: "" + val hasLock = errMsg.contains("lock", ignoreCase = true) || errMsg.contains("already locked", ignoreCase = true) _uiState.update { it.copy(resticStatus = ResticStatus( - message = "仓库未初始化或认证失败", - initButtonVisible = true, statsButtonVisible = false, pruneButtonVisible = false + message = if (hasLock) "仓库被锁定,请先解锁" else "仓库未初始化或认证失败", + initButtonVisible = !hasLock, statsButtonVisible = false, pruneButtonVisible = false, + unlockButtonVisible = hasLock ))} } } } + fun unlockResticRepo(form: ResticForm) { + _uiState.update { it.copy(resticStatus = it.resticStatus.copy( + message = "正在解锁仓库…", unlockButtonEnabled = false + ))} + viewModelScope.launch { + ResticWrapper.backendDomain = form.backendDomain + val result = ResticWrapper.unlock(form.repo, form.password, + backend = form.backend, backendUrl = form.backendUrl, + backendUser = form.backendUser, backendPass = form.backendPass, + backendShare = form.backendShare, + ) + _uiState.update { it.copy(resticStatus = it.resticStatus.copy( + message = if (result.isSuccess) "解锁完成" else "解锁失败: ${result.errorOrNull()?.message}", + unlockButtonEnabled = true + ))} + refreshResticStatus(form) + } + } + fun showResticStats(form: ResticForm) { _uiState.update { it.copy(resticStatus = it.resticStatus.copy( message = "正在读取统计…", statsButtonEnabled = false @@ -303,6 +328,15 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application) viewModelScope.launch { try { _operationEvents.emit(OperationEvent.PruneStarted) + + // Remove stale locks before forget/prune + ResticWrapper.backendDomain = form.backendDomain + ResticWrapper.unlock(form.repo, form.password, + backend = form.backend, backendUrl = form.backendUrl, + backendUser = form.backendUser, backendPass = form.backendPass, + backendShare = form.backendShare, + ) + val forgetResult = ResticWrapper.forget(form.repo, form.password, keepDaily = 7, keepWeekly = 4, keepMonthly = 3, backend = form.backend, backendUrl = form.backendUrl, diff --git a/app/src/main/java/com/example/androidbackupgui/ui/RestoreFragment.kt b/app/src/main/java/com/example/androidbackupgui/ui/RestoreFragment.kt index 77d20b5..5159606 100644 --- a/app/src/main/java/com/example/androidbackupgui/ui/RestoreFragment.kt +++ b/app/src/main/java/com/example/androidbackupgui/ui/RestoreFragment.kt @@ -83,8 +83,8 @@ class RestoreFragment : Fragment() { val names = userList.map { (id, name) -> "$name (ID: $id)" } val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names) adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - binding.userSelector.adapter = adapter - binding.userSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + binding.restoreUserSelector.adapter = adapter + binding.restoreUserSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { selectedUserId = userList.getOrNull(position)?.first ?: 0 } diff --git a/app/src/main/res/layout/fragment_backup.xml b/app/src/main/res/layout/fragment_backup.xml index 4dd4ef7..a2c2beb 100644 --- a/app/src/main/res/layout/fragment_backup.xml +++ b/app/src/main/res/layout/fragment_backup.xml @@ -7,6 +7,7 @@ android:padding="@dimen/fragment_horizontal_padding" android:background="?attr/colorSurface"> + - - - - - + android:minWidth="80dp" + android:spinnerMode="dropdown" /> + + + - - + android:text="修改" + android:textSize="12sp" /> + + @@ -144,46 +136,49 @@ android:id="@+id/showSystemSwitch" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="显示系统应用" - android:textAppearance="@style/TextAppearance.Material3.BodySmall" + android:text="系统应用" + android:textAppearance="@style/TextAppearance.Material3.LabelSmall" android:checked="false" /> + + + + - - + android:paddingBottom="4dp" /> diff --git a/app/src/main/res/layout/fragment_config.xml b/app/src/main/res/layout/fragment_config.xml index 6fa3d33..d37087f 100644 --- a/app/src/main/res/layout/fragment_config.xml +++ b/app/src/main/res/layout/fragment_config.xml @@ -173,7 +173,8 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" app:singleSelection="true" - app:selectionRequired="true"> + app:selectionRequired="true" + app:checkedButton="@id/resticBackendLocal"> + + diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..4ed2a71 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,77 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %OS%==Windows_NT setlocal + +:omega \ No newline at end of file