diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0d6bb44..75fccd4 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -13,8 +13,8 @@ plugins {
buildscript {
dependencies {
- classpath(libs.jgit.org.eclipse.jgit)
- classpath(libs.jgit.org.eclipse.jgit.archive)
+ classpath(libs.jgit)
+ classpath(libs.jgit.archive)
}
}
@@ -185,6 +185,7 @@ android {
dependencies {
implementation(libs.activity.ktx)
implementation(libs.appcompat)
+ implementation(libs.biometric)
implementation(libs.core.ktx)
implementation(libs.fragment.ktx)
implementation(libs.preference.ktx)
diff --git a/app/src/main/java/com/chiller3/rsaf/Preferences.kt b/app/src/main/java/com/chiller3/rsaf/Preferences.kt
index e661020..f2e0abb 100644
--- a/app/src/main/java/com/chiller3/rsaf/Preferences.kt
+++ b/app/src/main/java/com/chiller3/rsaf/Preferences.kt
@@ -14,9 +14,10 @@ class Preferences(context: Context) {
const val PREF_ADD_FILE_EXTENSION = "add_file_extension"
const val PREF_ALLOW_BACKUP = "allow_backup"
const val PREF_DIALOGS_AT_BOTTOM = "dialogs_at_bottom"
+ const val PREF_LOCAL_STORAGE_ACCESS = "local_storage_access"
const val PREF_POSIX_LIKE_SEMANTICS = "posix_like_semantics"
const val PREF_PRETEND_LOCAL = "pretend_local"
- const val PREF_LOCAL_STORAGE_ACCESS = "local_storage_access"
+ const val PREF_REQUIRE_AUTH = "require_auth"
const val PREF_VERBOSE_RCLONE_LOGS = "verbose_rclone_logs"
// UI actions only
@@ -65,6 +66,11 @@ class Preferences(context: Context) {
get() = prefs.getBoolean(PREF_DIALOGS_AT_BOTTOM, false)
set(enabled) = prefs.edit { putBoolean(PREF_DIALOGS_AT_BOTTOM, enabled) }
+ /** Whether biometric or device credential auth is required. */
+ var requireAuth: Boolean
+ get() = prefs.getBoolean(PREF_REQUIRE_AUTH, false)
+ set(enabled) = prefs.edit { putBoolean(PREF_REQUIRE_AUTH, enabled) }
+
/** Whether to allow app data backups. */
var allowBackup: Boolean
get() = prefs.getBoolean(PREF_ALLOW_BACKUP, false)
diff --git a/app/src/main/java/com/chiller3/rsaf/SettingsActivity.kt b/app/src/main/java/com/chiller3/rsaf/SettingsActivity.kt
index 64f80e2..99684e8 100644
--- a/app/src/main/java/com/chiller3/rsaf/SettingsActivity.kt
+++ b/app/src/main/java/com/chiller3/rsaf/SettingsActivity.kt
@@ -7,13 +7,22 @@ class SettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.settings_activity)
+
+ val transaction = supportFragmentManager.beginTransaction()
+
+ // https://issuetracker.google.com/issues/181805603
+ val bioFragment = supportFragmentManager
+ .findFragmentByTag("androidx.biometric.BiometricFragment")
+ if (bioFragment != null) {
+ transaction.remove(bioFragment)
+ }
+
if (savedInstanceState == null) {
- supportFragmentManager
- .beginTransaction()
- .replace(R.id.settings, SettingsFragment())
- .commit()
+ transaction.replace(R.id.settings, SettingsFragment())
}
+ transaction.commit()
+
setSupportActionBar(findViewById(R.id.toolbar))
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/chiller3/rsaf/SettingsFragment.kt b/app/src/main/java/com/chiller3/rsaf/SettingsFragment.kt
index cc3a67c..8cc9a89 100644
--- a/app/src/main/java/com/chiller3/rsaf/SettingsFragment.kt
+++ b/app/src/main/java/com/chiller3/rsaf/SettingsFragment.kt
@@ -7,7 +7,11 @@ import android.os.Bundle
import android.os.Environment
import android.provider.DocumentsContract
import android.provider.Settings
+import android.view.View
+import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.biometric.BiometricManager.Authenticators
+import androidx.biometric.BiometricPrompt
import androidx.fragment.app.FragmentResultListener
import androidx.fragment.app.clearFragmentResult
import androidx.fragment.app.viewModels
@@ -23,6 +27,7 @@ import androidx.preference.size
import com.chiller3.rsaf.binding.rcbridge.Rcbridge
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
+import kotlin.math.absoluteValue
class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener,
Preference.OnPreferenceClickListener, LongClickablePreference.OnPreferenceLongClickListener,
@@ -40,6 +45,11 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener,
"${SettingsFragment::class.java.simpleName}.import_export_password"
private const val ARG_OLD_REMOTE_NAME = "old_remote_name"
+
+ private const val STATE_AUTHENTICATED = "authenticated"
+ private const val STATE_LAST_PAUSE = "last_pause"
+
+ private const val INACTIVE_TIMEOUT_NS = 60_000_000_000L
}
private val viewModel: SettingsViewModel by viewModels()
@@ -54,6 +64,9 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener,
private lateinit var prefExportConfiguration: Preference
private lateinit var prefVersion: LongClickablePreference
private lateinit var prefSaveLogs: Preference
+ private lateinit var bioPrompt: BiometricPrompt
+ private var bioAuthenticated = false
+ private var lastPause = 0L
private val requestSafImportConfiguration =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
@@ -77,9 +90,14 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener,
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.preferences_root, rootKey)
- val context = requireContext()
+ if (savedInstanceState != null) {
+ bioAuthenticated = savedInstanceState.getBoolean(STATE_AUTHENTICATED)
+ lastPause = savedInstanceState.getLong(STATE_LAST_PAUSE)
+ }
+
+ val activity = requireActivity()
- prefs = Preferences(context)
+ prefs = Preferences(activity)
categoryRemotes = findPreference(Preferences.CATEGORY_REMOTES)!!
categoryConfiguration = findPreference(Preferences.CATEGORY_CONFIGURATION)!!
@@ -107,6 +125,34 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener,
refreshVersion()
refreshDebugPrefs()
+ bioPrompt = BiometricPrompt(
+ this,
+ activity.mainExecutor,
+ object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+ Toast.makeText(
+ activity,
+ getString(R.string.biometric_error, errString),
+ Toast.LENGTH_LONG,
+ ).show()
+ activity.finish()
+ }
+
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ bioAuthenticated = true
+ refreshGlobalVisibility()
+ }
+
+ override fun onAuthenticationFailed() {
+ Toast.makeText(
+ activity,
+ R.string.biometric_failure,
+ Toast.LENGTH_LONG,
+ ).show()
+ activity.finish()
+ }
+ })
+
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.alerts.collect {
@@ -137,7 +183,7 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener,
continue
}
- val p = Preference(context).apply {
+ val p = Preference(activity).apply {
key = Preferences.PREF_EDIT_REMOTE_PREFIX + remote.name
isPersistent = false
title = remote.name
@@ -196,6 +242,13 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener,
}
}
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+
+ outState.putBoolean(STATE_AUTHENTICATED, bioAuthenticated)
+ outState.putLong(STATE_LAST_PAUSE, lastPause)
+ }
+
override fun onResume() {
super.onResume()
@@ -204,6 +257,46 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener,
} else {
prefLocalStorageAccess.isVisible = false
}
+
+ val now = System.nanoTime()
+
+ if (bioAuthenticated && (now - lastPause).absoluteValue >= INACTIVE_TIMEOUT_NS) {
+ bioAuthenticated = false
+ }
+
+ if (!bioAuthenticated) {
+ if (!prefs.requireAuth) {
+ bioAuthenticated = true
+ } else {
+ startBiometricAuth()
+ }
+ }
+
+ refreshGlobalVisibility()
+ }
+
+ override fun onPause() {
+ super.onPause()
+
+ lastPause = System.nanoTime()
+ }
+
+ private fun startBiometricAuth() {
+ val promptInfo = BiometricPrompt.PromptInfo.Builder()
+ .setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL)
+ .setTitle(getString(R.string.biometric_title))
+ .build()
+
+ bioPrompt.authenticate(promptInfo)
+ }
+
+ private fun refreshGlobalVisibility() {
+ view?.visibility = if (bioAuthenticated) {
+ View.VISIBLE
+ } else {
+ // Using View.GONE causes noticeable scrolling jank due to relayout.
+ View.INVISIBLE
+ }
}
private fun refreshVersion() {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c2d0837..1d1c6fa 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -26,6 +26,8 @@
Allows wrapper remotes, like crypt, to access local storage under /sdcard.
Show dialogs at bottom
Makes one-handed use easier and prevents dialog buttons from shifting.
+ Require authentication
+ Require biometric unlock or screen lock PIN/password to view and change RSAF settings.
Allow Android backups
Allow app data to be backed up if Android claims that the transport is end-to-end encrypted or if it is being directly transferred to another device. RSAF cannot know if either of these conditions are actually true.
Version
@@ -58,6 +60,11 @@
Successfully saved logs to %1$s
Failed to save logs to %1$s: %2$s
+
+ Unlock configuration
+ Biometric authentication error: %1$s
+ Biometric authentication failed
+
Next
OK
diff --git a/app/src/main/res/xml/preferences_root.xml b/app/src/main/res/xml/preferences_root.xml
index db59d05..8668445 100644
--- a/app/src/main/res/xml/preferences_root.xml
+++ b/app/src/main/res/xml/preferences_root.xml
@@ -73,6 +73,12 @@
app:title="@string/pref_dialogs_at_bottom_name"
app:summary="@string/pref_dialogs_at_bottom_desc"
app:iconSpaceReserved="false" />
+
+
+
+
+
+
+
+
+
+
@@ -198,6 +206,14 @@
+
+
+
+
+
+
+
+
@@ -211,6 +227,14 @@
+
+
+
+
+
+
+
+
@@ -362,6 +386,11 @@
+
+
+
+
+