Skip to content

Commit

Permalink
Make inactivity timeout configurable and add explicit lock button
Browse files Browse the repository at this point in the history
Signed-off-by: Andrew Gunnerson <accounts+github@chiller3.com>
  • Loading branch information
chenxiaolong committed Dec 17, 2024
1 parent 8b5c8eb commit aa330ba
Show file tree
Hide file tree
Showing 8 changed files with 264 additions and 21 deletions.
83 changes: 83 additions & 0 deletions app/src/main/java/com/chiller3/rsaf/AppLock.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson
* SPDX-License-Identifier: GPL-3.0-only
*/

package com.chiller3.rsaf

import android.content.Context
import android.util.Log

object AppLock {
private sealed interface State {
val isLocked: Boolean

data object Active : State {
override val isLocked: Boolean = false
}

data class Inactive(val pauseTime: Long) : State {
override val isLocked: Boolean
get() = System.nanoTime() - pauseTime >= prefs.inactivityTimeout * 1_000_000_000L
}

data object Locked : State {
override val isLocked: Boolean = true
}
}

private val TAG = AppLock::class.java.simpleName

private lateinit var appContext: Context
private lateinit var prefs: Preferences
private var state: State = State.Locked
set(s) {
Log.d(TAG, "State changed: $s")
field = s
}

val isLocked: Boolean
get() = state.isLocked

fun init(context: Context) {
appContext = context.applicationContext
prefs = Preferences(appContext)

if (!prefs.requireAuth) {
state = State.Active
}
}

fun onAppResume() {
state.let {
if (it is State.Inactive) {
if (it.isLocked) {
Log.d(TAG, "Timed out due to inactivity")
state = State.Locked
} else {
Log.d(TAG, "App is active again")
state = State.Active
}
}
}
}

fun onAppPause() {
if (prefs.requireAuth && state == State.Active) {
Log.d(TAG, "App is inactive")
state = State.Inactive(System.nanoTime())
}
}

fun onAuthSuccess() {
Log.d(TAG, "Authentication succeeded")
state = State.Active
}

fun onLock() {
if (prefs.requireAuth) {
Log.d(TAG, "User requested immediate locking")
state = State.Locked
}
}
}
2 changes: 2 additions & 0 deletions app/src/main/java/com/chiller3/rsaf/MainApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ class MainApplication : Application(), SharedPreferences.OnSharedPreferenceChang

backupManager = BackupManager(this)

AppLock.init(this)

Notifications(this).updateChannels()

// Enable Material You colors
Expand Down
28 changes: 7 additions & 21 deletions app/src/main/java/com/chiller3/rsaf/PreferenceBaseActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,6 @@ import com.chiller3.rsaf.databinding.SettingsActivityBinding

abstract class PreferenceBaseActivity : AppCompatActivity() {
companion object {
private const val INACTIVE_TIMEOUT_NS = 60_000_000_000L

// These are intentionally global to ensure that the prompt does not appear when navigating
// within the app.
private var bioAuthenticated = false
private var lastPause = 0L

private fun supportsModernDeviceCredential() =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
}
Expand Down Expand Up @@ -162,17 +155,10 @@ abstract class PreferenceBaseActivity : AppCompatActivity() {
super.onResume()
Log.d(tag, "onResume()")

if (bioAuthenticated && (System.nanoTime() - lastPause) >= INACTIVE_TIMEOUT_NS) {
Log.d(tag, "Biometric authentication timed out due to inactivity")
bioAuthenticated = false
}
AppLock.onAppResume()

if (!bioAuthenticated) {
if (!prefs.requireAuth) {
bioAuthenticated = true
} else {
startAuth()
}
if (AppLock.isLocked) {
startAuth()
}

refreshTaskState()
Expand All @@ -183,7 +169,7 @@ abstract class PreferenceBaseActivity : AppCompatActivity() {
super.onPause()
Log.d(tag, "onPause()")

lastPause = System.nanoTime()
AppLock.onAppPause()
}

override fun onWindowFocusChanged(hasFocus: Boolean) {
Expand All @@ -206,7 +192,7 @@ abstract class PreferenceBaseActivity : AppCompatActivity() {

// We want the activity to be visible for predictive back gestures as long as the top-level
// activity in the task is our own.
private fun canViewAndInteract() = bioAuthenticated || isCoveredBySafeActivity
private fun canViewAndInteract() = !AppLock.isLocked || isCoveredBySafeActivity

override fun onWindowAttributesChanged(params: WindowManager.LayoutParams?) {
val canInteract = canViewAndInteract()
Expand Down Expand Up @@ -246,7 +232,6 @@ abstract class PreferenceBaseActivity : AppCompatActivity() {
bioPrompt.authenticate(promptInfo)
}


private fun startLegacyDeviceCredentialAuth() {
Log.d(tag, "Starting legacy device credential authentication")

Expand Down Expand Up @@ -282,7 +267,8 @@ abstract class PreferenceBaseActivity : AppCompatActivity() {
private fun onAuthenticationSucceeded() {
Log.d(tag, "Authentication succeeded")

bioAuthenticated = true
AppLock.onAuthSuccess()

refreshGlobalVisibility()
}

Expand Down
15 changes: 15 additions & 0 deletions app/src/main/java/com/chiller3/rsaf/Preferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import android.content.Context
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import kotlin.math.max

class Preferences(private val context: Context) {
companion object {
Expand All @@ -25,6 +26,8 @@ class Preferences(private val context: Context) {
const val PREF_POSIX_LIKE_SEMANTICS = "posix_like_semantics"
const val PREF_PRETEND_LOCAL = "pretend_local"
const val PREF_REQUIRE_AUTH = "require_auth"
const val PREF_INACTIVITY_TIMEOUT = "inactivity_timeout"
const val PREF_LOCK_NOW = "lock_now"
const val PREF_VERBOSE_RCLONE_LOGS = "verbose_rclone_logs"

// Main UI actions only
Expand All @@ -51,6 +54,11 @@ class Preferences(private val context: Context) {
// Not associated with a UI preference
const val PREF_DEBUG_MODE = "debug_mode"
private const val PREF_NEXT_NOTIFICATION_ID = "next_notification_id"

// This needs to be large enough to account for activity transitions, where the lock state
// will briefly become inactive. We also need to make sure it's high enough that the user
// can't lock themselves out.
const val MIN_INACTIVITY_TIMEOUT = 15
}

private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
Expand Down Expand Up @@ -92,6 +100,13 @@ class Preferences(private val context: Context) {
get() = prefs.getBoolean(PREF_REQUIRE_AUTH, false)
set(enabled) = prefs.edit { putBoolean(PREF_REQUIRE_AUTH, enabled) }

/** Inactivity timeout (in seconds). */
var inactivityTimeout: Int
get() = max(prefs.getInt(PREF_INACTIVITY_TIMEOUT, 60), MIN_INACTIVITY_TIMEOUT)
set(seconds) = prefs.edit {
putInt(PREF_INACTIVITY_TIMEOUT, max(seconds, MIN_INACTIVITY_TIMEOUT))
}

/** Whether to allow app data backups. */
var allowBackup: Boolean
get() = prefs.getBoolean(PREF_ALLOW_BACKUP, false)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* SPDX-FileCopyrightText: 2024 Andrew Gunnerson
* SPDX-License-Identifier: GPL-3.0-only
*/

package com.chiller3.rsaf.dialog

import android.annotation.SuppressLint
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.text.InputType
import android.view.Gravity
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult
import com.chiller3.rsaf.Preferences
import com.chiller3.rsaf.R
import com.chiller3.rsaf.databinding.DialogTextInputBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder

class InactivityTimeoutDialogFragment : DialogFragment() {
companion object {
val TAG = InactivityTimeoutDialogFragment::class.java.simpleName

const val RESULT_SUCCESS = "success"
}

private lateinit var prefs: Preferences
private lateinit var binding: DialogTextInputBinding
private var duration: Int? = null
private var success: Boolean = false

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
prefs = Preferences(requireContext())

binding = DialogTextInputBinding.inflate(layoutInflater)
binding.message.text = getString(R.string.dialog_inactivity_timeout_message)
binding.text.inputType = InputType.TYPE_CLASS_NUMBER
binding.text.addTextChangedListener {
duration = try {
val seconds = it.toString().toInt()
if (seconds >= Preferences.MIN_INACTIVITY_TIMEOUT) {
seconds
} else {
null
}
} catch (_: Exception) {
null
}

refreshOkButtonEnabledState()
}
if (savedInstanceState == null) {
@SuppressLint("SetTextI18n")
binding.text.setText(prefs.inactivityTimeout.toString())
}

return MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.dialog_inactivity_timeout_title)
.setView(binding.root)
.setPositiveButton(R.string.dialog_action_ok) { _, _ ->
prefs.inactivityTimeout = duration!!
success = true
}
.setNegativeButton(R.string.dialog_action_cancel, null)
.create()
.apply {
if (Preferences(requireContext()).dialogsAtBottom) {
window!!.attributes.gravity = Gravity.BOTTOM
}
}
}

override fun onStart() {
super.onStart()
refreshOkButtonEnabledState()
}

override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)

setFragmentResult(tag!!, bundleOf(RESULT_SUCCESS to success))
}

private fun refreshOkButtonEnabledState() {
(dialog as AlertDialog?)?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled =
duration != null
}
}
35 changes: 35 additions & 0 deletions app/src/main/java/com/chiller3/rsaf/settings/SettingsFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ import androidx.preference.PreferenceCategory
import androidx.preference.SwitchPreferenceCompat
import androidx.preference.get
import androidx.preference.size
import com.chiller3.rsaf.AppLock
import com.chiller3.rsaf.BuildConfig
import com.chiller3.rsaf.Logcat
import com.chiller3.rsaf.Permissions
import com.chiller3.rsaf.PreferenceBaseFragment
import com.chiller3.rsaf.Preferences
import com.chiller3.rsaf.R
import com.chiller3.rsaf.binding.rcbridge.Rcbridge
import com.chiller3.rsaf.dialog.InactivityTimeoutDialogFragment
import com.chiller3.rsaf.dialog.InteractiveConfigurationDialogFragment
import com.chiller3.rsaf.dialog.RemoteNameDialogAction
import com.chiller3.rsaf.dialog.RemoteNameDialogFragment
Expand Down Expand Up @@ -75,6 +77,8 @@ class SettingsFragment : PreferenceBaseFragment(), FragmentResultListener,
private lateinit var prefLocalStorageAccess: SwitchPreferenceCompat
private lateinit var prefImportConfiguration: Preference
private lateinit var prefExportConfiguration: Preference
private lateinit var prefInactivityTimeout: Preference
private lateinit var prefLockNow: Preference
private lateinit var prefVersion: LongClickablePreference
private lateinit var prefSaveLogs: Preference

Expand Down Expand Up @@ -146,6 +150,12 @@ class SettingsFragment : PreferenceBaseFragment(), FragmentResultListener,
prefExportConfiguration = findPreference(Preferences.PREF_EXPORT_CONFIGURATION)!!
prefExportConfiguration.onPreferenceClickListener = this

prefInactivityTimeout = findPreference(Preferences.PREF_INACTIVITY_TIMEOUT)!!
prefInactivityTimeout.onPreferenceClickListener = this

prefLockNow = findPreference(Preferences.PREF_LOCK_NOW)!!
prefLockNow.onPreferenceClickListener = this

prefVersion = findPreference(Preferences.PREF_VERSION)!!
prefVersion.onPreferenceClickListener = this
prefVersion.onPreferenceLongClickListener = this
Expand All @@ -157,6 +167,7 @@ class SettingsFragment : PreferenceBaseFragment(), FragmentResultListener,
// onResume() because allowing the permissions does not restart the activity.
refreshPermissions()

refreshInactivityTimeout()
refreshVersion()
refreshDebugPrefs()

Expand Down Expand Up @@ -253,6 +264,7 @@ class SettingsFragment : PreferenceBaseFragment(), FragmentResultListener,
TAG_ADD_REMOTE_NAME,
TAG_IMPORT_EXPORT_PASSWORD,
InteractiveConfigurationDialogFragment.TAG,
InactivityTimeoutDialogFragment.TAG,
)) {
parentFragmentManager.setFragmentResultListener(key, this, this)
}
Expand Down Expand Up @@ -282,6 +294,14 @@ class SettingsFragment : PreferenceBaseFragment(), FragmentResultListener,
categoryPermissions.isVisible = !(allowedInhibitBatteryOpt && allowedNotifications)
}

private fun refreshInactivityTimeout() {
prefInactivityTimeout.summary = requireContext().resources.getQuantityString(
R.plurals.pref_inactivity_timeout_desc,
prefs.inactivityTimeout,
prefs.inactivityTimeout,
)
}

private fun refreshVersion() {
prefVersion.summary = buildString {
append(BuildConfig.VERSION_NAME)
Expand Down Expand Up @@ -333,6 +353,9 @@ class SettingsFragment : PreferenceBaseFragment(), FragmentResultListener,
bundle.getBoolean(InteractiveConfigurationDialogFragment.RESULT_CANCELLED),
)
}
InactivityTimeoutDialogFragment.TAG -> {
refreshInactivityTimeout()
}
}
}

Expand Down Expand Up @@ -370,6 +393,18 @@ class SettingsFragment : PreferenceBaseFragment(), FragmentResultListener,
requestSafExportConfiguration.launch(RcloneConfig.FILENAME)
return true
}
preference === prefInactivityTimeout -> {
InactivityTimeoutDialogFragment().show(
parentFragmentManager.beginTransaction(),
InactivityTimeoutDialogFragment.TAG,
)
return true
}
preference === prefLockNow -> {
AppLock.onLock()
requireActivity().finishAndRemoveTask()
return true
}
preference === prefVersion -> {
val uri = Uri.parse(BuildConfig.PROJECT_URL_AT_COMMIT)
startActivity(Intent(Intent.ACTION_VIEW, uri))
Expand Down
Loading

0 comments on commit aa330ba

Please sign in to comment.