Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to lock settings behind biometric or device credential auth #32

Merged
merged 1 commit into from
Sep 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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))
}
}
98 changes: 95 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,35 @@ 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 +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
Expand Down Expand Up @@ -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_PAUSE, lastPause)
}

override fun onResume() {
super.onResume()

Expand All @@ -204,6 +258,44 @@ class SettingsFragment : PreferenceFragmentCompat(), FragmentResultListener,
} else {
prefLocalStorageAccess.isVisible = false
}

if (bioAuthenticated && (System.nanoTime() - lastPause) >= 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