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..325b15f 100644 --- a/app/src/main/java/com/chiller3/rsaf/SettingsFragment.kt +++ b/app/src/main/java/com/chiller3/rsaf/SettingsFragment.kt @@ -7,7 +7,12 @@ 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.core.view.isVisible import androidx.fragment.app.FragmentResultListener import androidx.fragment.app.clearFragmentResult import androidx.fragment.app.viewModels @@ -23,6 +28,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 +46,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_RESUME = "last_resume" + + private const val INACTIVE_TIMEOUT_NS = 60_000_000_000L } private val viewModel: SettingsViewModel by viewModels() @@ -54,6 +65,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 +91,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_RESUME) + } + + val activity = requireActivity() - prefs = Preferences(context) + prefs = Preferences(activity) categoryRemotes = findPreference(Preferences.CATEGORY_REMOTES)!! categoryConfiguration = findPreference(Preferences.CATEGORY_CONFIGURATION)!! @@ -107,6 +126,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 +184,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 +243,13 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener, } } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + outState.putBoolean(STATE_AUTHENTICATED, bioAuthenticated) + outState.putLong(STATE_LAST_RESUME, lastPause) + } + override fun onResume() { super.onResume() @@ -204,6 +258,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 @@ + + + + +