Skip to content

Commit

Permalink
Add option to lock settings behind biometric or device credential auth
Browse files Browse the repository at this point in the history
Issue: #27

Signed-off-by: Andrew Gunnerson <accounts+github@chiller3.com>
  • Loading branch information
chenxiaolong committed Sep 11, 2023
1 parent a0af756 commit ca7d506
Show file tree
Hide file tree
Showing 8 changed files with 175 additions and 22 deletions.
5 changes: 3 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion app/src/main/java/com/chiller3/rsaf/Preferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 13 additions & 4 deletions app/src/main/java/com/chiller3/rsaf/SettingsActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
99 changes: 96 additions & 3 deletions app/src/main/java/com/chiller3/rsaf/SettingsFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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()
Expand All @@ -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 ->
Expand All @@ -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)!!
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand All @@ -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() {
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
<string name="pref_local_storage_access_desc">Allows wrapper remotes, like <tt>crypt</tt>, to access local storage under <tt>/sdcard</tt>.</string>
<string name="pref_dialogs_at_bottom_name">Show dialogs at bottom</string>
<string name="pref_dialogs_at_bottom_desc">Makes one-handed use easier and prevents dialog buttons from shifting.</string>
<string name="pref_require_auth_name">Require authentication</string>
<string name="pref_require_auth_desc">Require biometric unlock or screen lock PIN/password to view and change RSAF settings.</string>
<string name="pref_allow_backup_name">Allow Android backups</string>
<string name="pref_allow_backup_desc">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.</string>
<string name="pref_version_name">Version</string>
Expand Down Expand Up @@ -58,6 +60,11 @@
<string name="alert_logcat_success">Successfully saved logs to %1$s</string>
<string name="alert_logcat_failure">Failed to save logs to %1$s: %2$s</string>

<!-- Biometric -->
<string name="biometric_title">Unlock configuration</string>
<string name="biometric_error">Biometric authentication error: %1$s</string>
<string name="biometric_failure">Biometric authentication failed</string>

<!-- Dialogs -->
<string name="dialog_action_next">Next</string>
<string name="dialog_action_ok">OK</string>
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/res/xml/preferences_root.xml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@
app:title="@string/pref_dialogs_at_bottom_name"
app:summary="@string/pref_dialogs_at_bottom_desc"
app:iconSpaceReserved="false" />

<SwitchPreferenceCompat
app:key="require_auth"
app:title="@string/pref_require_auth_name"
app:summary="@string/pref_require_auth_desc"
app:iconSpaceReserved="false" />
</PreferenceCategory>

<PreferenceCategory
Expand Down
26 changes: 14 additions & 12 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
activity-ktx = "1.7.2"
android-gradle-plugin = "8.1.1"
appcompat = "1.6.1"
biometric = "1.1.0"
core-ktx = "1.10.1"
jgit = "6.5.0.202303070854-r"
espresso-core = "3.5.1"
Expand All @@ -14,18 +15,19 @@ preference-ktx = "1.2.1"
security-crypto = "1.1.0-alpha06"

[libraries]
activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activity-ktx" }
appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso-core" }
fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragment-ktx" }
jgit-org-eclipse-jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version.ref = "jgit" }
jgit-org-eclipse-jgit-archive = { module = "org.eclipse.jgit:org.eclipse.jgit.archive", version.ref = "jgit" }
jsr305 = { module = "com.google.code.findbugs:jsr305", version.ref = "jsr305" }
junit = { module = "androidx.test.ext:junit", version.ref = "junit" }
material = { module = "com.google.android.material:material", version.ref = "material" }
preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preference-ktx" }
security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" }
activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity-ktx" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" }
fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment-ktx" }
jgit = { group = "org.eclipse.jgit", name = "org.eclipse.jgit", version.ref = "jgit" }
jgit-archive = { group = "org.eclipse.jgit", name = "org.eclipse.jgit.archive", version.ref = "jgit" }
jsr305 = { group = "com.google.code.findbugs", name = "jsr305", version.ref = "jsr305" }
junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preference-ktx" }
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" }

[plugins]
android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" }
Expand Down
29 changes: 29 additions & 0 deletions gradle/verification-metadata.xml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@
<sha512 value="6c766b16b2369289eaf423b176583b7dfb001309f8a0f4c55fb687dbe032a89df606811ecdb0e00adad8c4b41a7c522643cf3155bcfffb20878e4943789d87b3" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.biometric" name="biometric" version="1.1.0">
<artifact name="biometric-1.1.0.aar">
<sha512 value="824c2e6855bb97bf8bc207a3469edcd58a5ca5f6292ad481973d81ce23e15f01095002393b7338deaa78f451ea3bf2603bbccc53ddce880da907496dcb1fa401" origin="Generated by Gradle"/>
</artifact>
<artifact name="biometric-1.1.0.module">
<sha512 value="e04ce163b79af8a02b826505fc69cf7aaf7081af7bac40deeb58c11e39cdc8173f9dd73544ad7a43a07787559bdcd6d24f50b8275fe5da122b550ad0a1bb5490" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.cardview" name="cardview" version="1.0.0">
<artifact name="cardview-1.0.0.aar">
<sha512 value="46f60b7bb2dcd5b379227705fe774fef33f931d463685cd96d6d9d7dfc43456183787e5f9420532495a9298233cf0b9d38317a5c5675139c97466e1dab69b71c" origin="Generated by Gradle"/>
Expand Down Expand Up @@ -198,6 +206,14 @@
<sha512 value="7f0c63ed6fb852c8cf2bb0b6eecc28a0bf0df88184e894cf7c8fd7e55dd461c0f38d027ed3bb7f72ca4b4de7a1cfa04bbe02c9096ef36c87693c6417c960ff8a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.core" name="core" version="1.12.0">
<artifact name="core-1.12.0.aar">
<sha512 value="189b9f984e192c29d892a82b78b6715c78b7a2ffa69f81a7d2027362277ce63c8317823db720a8668644cae02a34e8c51f92702282f53faad600667bd5f56b2d" origin="Generated by Gradle"/>
</artifact>
<artifact name="core-1.12.0.module">
<sha512 value="4bee939f8dcae448a334b4dfcbe865fb0b0d850778f1290c07381b2d7954fe112aeb76fea2bb1a414fae3d4ed3eec483b7429f57c45a3be5cd0d4d2d159a5c99" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.core" name="core" version="1.9.0">
<artifact name="core-1.9.0.module">
<sha512 value="6d30c1985abb279dab3c4cd760525d25c4ceb8635b933e7da945854a3673baff6bafea3f021fed338461f5f2b99bad8382f8273132560b983fdad44bdbacf405" origin="Generated by Gradle"/>
Expand All @@ -211,6 +227,14 @@
<sha512 value="3faea70f86c22615b4cf65a53af8a0559d4477918059e21e2177d83c06f4a061ebec59af00e3c695653a59bada88f5c7ac1a3812c0d82cfa2c3d4aaa07dbe306" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.core" name="core-ktx" version="1.12.0">
<artifact name="core-ktx-1.12.0.aar">
<sha512 value="999a6f12b60a3c140e8183f83e001b9d7c6a511fbeaeb6faf1cd31cbb6953758ed758d10e054472f64cdb7ab3d396bb8489a3ced56458e5be1401d705438075c" origin="Generated by Gradle"/>
</artifact>
<artifact name="core-ktx-1.12.0.module">
<sha512 value="f0563b3ccd9471e9d51a682c51a02b62132e4b6433899d6c27f041bd0499f7a7e6e051fed197cc4505d862769dc0886c993a7211812de8ee514af007f749f045" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.cursoradapter" name="cursoradapter" version="1.0.0">
<artifact name="cursoradapter-1.0.0.aar">
<sha512 value="176ac8e4604749ef5bb7f444c24add479f1d2cc5caab1046bbb35f937bf1cfc4329786cd1fdac3d6ee92fec78cb55ab89e5169ae5c6ee24dd4d20243aa867891" origin="Generated by Gradle"/>
Expand Down Expand Up @@ -362,6 +386,11 @@
<sha512 value="a51b3a93e045770c14a28b039da67b8fc3731c27a201f7de3de41bbc33a400766d0d70ac356fac50585d8f54ce7885cd489cf1b79d0437a634f28b5de131453b" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-livedata-core" version="2.2.0">
<artifact name="lifecycle-livedata-core-2.2.0.pom">
<sha512 value="5ad2c1db0a9d0004c56a50f617b1cefc848beb3cadbc3d70f33fc8d29ece6fb8a01a21a10b2f2b9835acdc2850934c88e47987ad322b4ddf478d8d8a57d4ce68" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-livedata-core" version="2.6.1">
<artifact name="lifecycle-livedata-core-2.6.1.aar">
<sha512 value="8f22135d674f653374da4324be539b7911c72fb63fbd797b08c22aea940c8a780765db639130cc47703dd49e4509c7fa11687dd26a320dc350c633d12fb88f27" origin="Generated by Gradle"/>
Expand Down

0 comments on commit ca7d506

Please sign in to comment.