Update screen lock.

This commit is contained in:
Michelle Tang
2024-08-08 14:24:08 -04:00
committed by mtang-signal
parent c880db0f4a
commit 3bdbd69a7d
40 changed files with 744 additions and 259 deletions

View File

@@ -109,8 +109,13 @@ class LearnMoreTextPreferenceViewHolder(itemView: View) : PreferenceViewHolder<L
class ClickPreferenceViewHolder(itemView: View) : PreferenceViewHolder<ClickPreference>(itemView) {
override fun bind(model: ClickPreference) {
super.bind(model)
itemView.setOnClickListener { model.onClick() }
itemView.setOnLongClickListener { model.onLongClick?.invoke() ?: false }
if (!itemView.isEnabled && model.onDisabledClicked != null) {
itemView.isEnabled = true
itemView.setOnClickListener { model.onDisabledClicked() }
} else {
itemView.setOnClickListener { model.onClick() }
itemView.setOnLongClickListener { model.onLongClick?.invoke() ?: false }
}
}
}

View File

@@ -21,6 +21,7 @@ import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.navArgs
import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BiometricDeviceAuthentication
import org.thoughtcrime.securesms.BiometricDeviceLockContract
@@ -36,9 +37,9 @@ import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.ExpirationUtil
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.SpanUtil
@@ -197,7 +198,7 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac
KeyCachingService.getMasterSecret(context),
MasterSecretUtil.UNENCRYPTED_PASSPHRASE
)
TextSecurePreferences.setPasswordDisabled(activity, true)
SignalStore.settings.passphraseDisabled = true
val intent = Intent(activity, KeyCachingService::class.java)
intent.action = KeyCachingService.DISABLE_ACTION
requireActivity().startService(intent)
@@ -249,33 +250,21 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac
} else {
val isKeyguardSecure = ServiceUtil.getKeyguardManager(requireContext()).isKeyguardSecure
switchPref(
title = DSLSettingsText.from(R.string.preferences_app_protection__screen_lock),
summary = DSLSettingsText.from(R.string.preferences_app_protection__lock_signal_access_with_android_screen_lock_or_fingerprint),
isChecked = state.screenLock && isKeyguardSecure,
isEnabled = isKeyguardSecure,
onClick = {
viewModel.setScreenLockEnabled(!state.screenLock)
val intent = Intent(requireContext(), KeyCachingService::class.java)
intent.action = KeyCachingService.LOCK_TOGGLED_EVENT
requireContext().startService(intent)
ConversationUtil.refreshRecipientShortcuts()
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences_app_protection__screen_lock_inactivity_timeout),
summary = DSLSettingsText.from(getScreenLockInactivityTimeoutSummary(state.screenLockActivityTimeout)),
isEnabled = isKeyguardSecure && state.screenLock,
title = DSLSettingsText.from(R.string.preferences_app_protection__screen_lock),
summary = DSLSettingsText.from(getScreenLockInactivityTimeoutSummary(isKeyguardSecure && state.screenLock, state.screenLockActivityTimeout)),
onClick = {
childFragmentManager.clearFragmentResult(TimeDurationPickerDialog.RESULT_DURATION)
childFragmentManager.clearFragmentResultListener(TimeDurationPickerDialog.RESULT_DURATION)
childFragmentManager.setFragmentResultListener(TimeDurationPickerDialog.RESULT_DURATION, this@PrivacySettingsFragment) { _, bundle ->
viewModel.setScreenLockTimeout(bundle.getLong(TimeDurationPickerDialog.RESULT_KEY_DURATION_MILLISECONDS).milliseconds.inWholeSeconds)
}
TimeDurationPickerDialog.create(state.screenLockActivityTimeout.seconds).show(childFragmentManager, null)
Navigation.findNavController(requireView()).safeNavigate(R.id.action_privacySettingsFragment_to_screenLockSettingsFragment)
},
isEnabled = isKeyguardSecure,
onDisabledClicked = {
Snackbar
.make(
requireView(),
resources.getString(R.string.preferences_app_protection__to_use_screen_lock),
Snackbar.LENGTH_LONG
)
.show()
}
)
}
@@ -362,9 +351,11 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac
}
}
private fun getScreenLockInactivityTimeoutSummary(timeoutSeconds: Long): String {
return if (timeoutSeconds <= 0) {
getString(R.string.AppProtectionPreferenceFragment_none)
private fun getScreenLockInactivityTimeoutSummary(enabledScreenLock: Boolean, timeoutSeconds: Long): String {
return if (!enabledScreenLock) {
getString(R.string.ScreenLockSettingsFragment__off)
} else if (timeoutSeconds == 0L) {
getString(R.string.ScreenLockSettingsFragment__immediately)
} else {
ExpirationUtil.getExpirationDisplayValue(requireContext(), timeoutSeconds.toInt())
}

View File

@@ -37,16 +37,6 @@ class PrivacySettingsViewModel(
refresh()
}
fun setScreenLockEnabled(enabled: Boolean) {
sharedPreferences.edit().putBoolean(TextSecurePreferences.SCREEN_LOCK, enabled).apply()
refresh()
}
fun setScreenLockTimeout(seconds: Long) {
TextSecurePreferences.setScreenLockTimeout(AppDependencies.application, seconds)
refresh()
}
fun setScreenSecurityEnabled(enabled: Boolean) {
sharedPreferences.edit().putBoolean(TextSecurePreferences.SCREEN_SECURITY_PREF, enabled).apply()
refresh()
@@ -63,12 +53,12 @@ class PrivacySettingsViewModel(
}
fun setObsoletePasswordTimeoutEnabled(enabled: Boolean) {
sharedPreferences.edit().putBoolean(TextSecurePreferences.PASSPHRASE_TIMEOUT_PREF, enabled).apply()
SignalStore.settings.passphraseTimeoutEnabled = enabled
refresh()
}
fun setObsoletePasswordTimeout(minutes: Int) {
TextSecurePreferences.setPassphraseTimeoutInterval(AppDependencies.application, minutes)
SignalStore.settings.passphraseTimeout = minutes
refresh()
}
@@ -81,14 +71,14 @@ class PrivacySettingsViewModel(
blockedCount = 0,
readReceipts = TextSecurePreferences.isReadReceiptsEnabled(AppDependencies.application),
typingIndicators = TextSecurePreferences.isTypingIndicatorsEnabled(AppDependencies.application),
screenLock = TextSecurePreferences.isScreenLockEnabled(AppDependencies.application),
screenLockActivityTimeout = TextSecurePreferences.getScreenLockTimeout(AppDependencies.application),
screenLock = SignalStore.settings.screenLockEnabled,
screenLockActivityTimeout = SignalStore.settings.screenLockTimeout,
screenSecurity = TextSecurePreferences.isScreenSecurityEnabled(AppDependencies.application),
incognitoKeyboard = TextSecurePreferences.isIncognitoKeyboardEnabled(AppDependencies.application),
paymentLock = SignalStore.payments.paymentLock,
isObsoletePasswordEnabled = !TextSecurePreferences.isPasswordDisabled(AppDependencies.application),
isObsoletePasswordTimeoutEnabled = TextSecurePreferences.isPassphraseTimeoutEnabled(AppDependencies.application),
obsoletePasswordTimeout = TextSecurePreferences.getPassphraseTimeoutInterval(AppDependencies.application),
isObsoletePasswordEnabled = !SignalStore.settings.passphraseDisabled,
isObsoletePasswordTimeoutEnabled = SignalStore.settings.passphraseTimeoutEnabled,
obsoletePasswordTimeout = SignalStore.settings.passphraseTimeout,
universalExpireTimer = SignalStore.settings.universalExpireTimer
)
}

View File

@@ -65,6 +65,12 @@ class CustomExpireTimerSelectorView @JvmOverloads constructor(
valuePicker.maxValue = timerUnit.maxValue
}
fun setUnits(minValue: Int, maxValue: Int, timeUnitRes: Int) {
unitPicker.minValue = minValue
unitPicker.maxValue = maxValue
unitPicker.displayedValues = context.resources.getStringArray(timeUnitRes)
}
private enum class TimerUnit(val minValue: Int, val maxValue: Int, val valueMultiplier: Long) {
SECONDS(1, 59, TimeUnit.SECONDS.toSeconds(1)),
MINUTES(1, 59, TimeUnit.MINUTES.toSeconds(1)),

View File

@@ -0,0 +1,37 @@
package org.thoughtcrime.securesms.components.settings.app.privacy.screenlock
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.privacy.expire.CustomExpireTimerSelectorView
/**
* Dialog for selecting a custom timer value when setting the screen lock timeout.
*/
class CustomScreenLockTimerSelectDialog : DialogFragment() {
private val viewModel: ScreenLockSettingsViewModel by activityViewModels()
private lateinit var selector: CustomExpireTimerSelectorView
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialogView: View = LayoutInflater.from(context).inflate(R.layout.custom_expire_timer_select_dialog, null, false)
selector = dialogView.findViewById(R.id.custom_expire_timer_select_dialog_selector)
selector.setUnits(1, 3, R.array.CustomScreenLockTimerSelectorView__unit_labels)
selector.setTimer(viewModel.state.value.screenLockActivityTimeout.toInt())
return MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ExpireTimerSettingsFragment__custom_time)
.setView(dialogView)
.setPositiveButton(R.string.ExpireTimerSettingsFragment__set) { _, _ ->
viewModel.setScreenLockTimeout(selector.getTimer().toLong())
}
.setNegativeButton(android.R.string.cancel, null)
.create()
}
}

View File

@@ -0,0 +1,284 @@
package org.thoughtcrime.securesms.components.settings.app.privacy.screenlock
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.activity.result.ActivityResultLauncher
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.activityViewModels
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BiometricDeviceAuthentication
import org.thoughtcrime.securesms.BiometricDeviceLockContract
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.ExpirationUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Fragment that allows user to turn on screen lock and set a timer to lock
*/
class ScreenLockSettingsFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(ScreenLockSettingsFragment::class)
}
private val viewModel: ScreenLockSettingsViewModel by activityViewModels()
private lateinit var biometricAuth: BiometricDeviceAuthentication
private lateinit var biometricDeviceLockLauncher: ActivityResultLauncher<String>
private lateinit var disableLockPromptInfo: BiometricPrompt.PromptInfo
private lateinit var enableLockPromptInfo: BiometricPrompt.PromptInfo
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
biometricDeviceLockLauncher = registerForActivityResult(BiometricDeviceLockContract()) { result: Int ->
if (result == BiometricDeviceAuthentication.AUTHENTICATED) {
toggleScreenLock()
}
}
enableLockPromptInfo = BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
.setTitle(requireContext().getString(R.string.ScreenLockSettingsFragment__use_signal_screen_lock))
.setConfirmationRequired(true)
.build()
disableLockPromptInfo = BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
.setTitle(requireContext().getString(R.string.ScreenLockSettingsFragment__turn_off_signal_lock))
.setConfirmationRequired(true)
.build()
biometricAuth = BiometricDeviceAuthentication(
BiometricManager.from(requireActivity()),
BiometricPrompt(requireActivity(), BiometricAuthenticationListener()),
enableLockPromptInfo
)
}
override fun onPause() {
super.onPause()
biometricAuth.cancelAuthentication()
}
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsState()
val navController: NavController by remember { mutableStateOf(findNavController()) }
Scaffolds.Settings(
title = stringResource(id = R.string.preferences_app_protection__screen_lock),
onNavigationClick = { navController.popBackStack() },
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { contentPadding: PaddingValues ->
ScreenLockScreen(
state = state,
onChecked = { checked ->
if (biometricAuth.canAuthenticate() && !checked) {
biometricAuth.updatePromptInfo(disableLockPromptInfo)
biometricAuth.authenticate(requireContext(), true) {
biometricDeviceLockLauncher.launch(getString(R.string.ScreenLockSettingsFragment__turn_off_signal_lock))
}
} else if (biometricAuth.canAuthenticate() && checked) {
biometricAuth.updatePromptInfo(enableLockPromptInfo)
biometricAuth.authenticate(requireContext(), true) {
biometricDeviceLockLauncher.launch(getString(R.string.ScreenLockSettingsFragment__use_screen_lock))
}
}
},
onTimeClicked = viewModel::setScreenLockTimeout,
onCustomTimeClicked = { navController.safeNavigate(R.id.action_screenLockSettingsFragment_to_customScreenLockTimerSelectDialog) },
modifier = Modifier.padding(contentPadding)
)
}
}
private fun toggleScreenLock() {
viewModel.toggleScreenLock()
val intent = Intent(requireContext(), KeyCachingService::class.java)
intent.action = KeyCachingService.LOCK_TOGGLED_EVENT
requireContext().startService(intent)
ConversationUtil.refreshRecipientShortcuts()
}
private inner class BiometricAuthenticationListener : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errorString: CharSequence) {
Log.w(TAG, "Authentication error: $errorCode")
onAuthenticationFailed()
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
Log.i(TAG, "Authentication succeeded")
toggleScreenLock()
}
override fun onAuthenticationFailed() {
Log.w(TAG, "Unable to authenticate")
}
}
}
@Composable
fun ScreenLockScreen(
state: ScreenLockSettingsState,
onChecked: (Boolean) -> Unit,
onTimeClicked: (Long) -> Unit,
onCustomTimeClicked: () -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
painter = painterResource(R.drawable.ic_screen_lock),
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.padding(top = 24.dp, bottom = 24.dp)
)
Text(
text = stringResource(id = R.string.ScreenLockSettingsFragment__your_android_device),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 40.dp, end = 40.dp, bottom = 24.dp)
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(horizontal = 24.dp)
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(24.dp))
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp)
) {
Text(stringResource(id = R.string.ScreenLockSettingsFragment__use_screen_lock))
Spacer(Modifier.weight(1f))
Switch(checked = state.screenLock, onCheckedChange = onChecked)
}
}
if (state.screenLock) {
val labels: List<String> = LocalContext.current.resources.getStringArray(R.array.ScreenLockSettingsFragment__labels).toList()
val values: List<Long> = LocalContext.current.resources.getIntArray(R.array.ScreenLockSettingsFragment__values).map { it.toLong() }
Text(
stringResource(id = R.string.ScreenLockSettingsFragment__start_screen_lock),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 24.dp, bottom = 16.dp, start = 24.dp)
)
Column(Modifier.selectableGroup()) {
var isCustomTime = true
labels.zip(values).forEach { (label, seconds) ->
val isSelected = seconds == state.screenLockActivityTimeout
Row(
Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 56.dp)
.selectable(
selected = isSelected,
onClick = { onTimeClicked(seconds) },
role = Role.RadioButton
)
.padding(horizontal = 24.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = isSelected, onClick = null)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 16.dp)
)
isCustomTime = isCustomTime && !isSelected
}
}
Row(
Modifier
.fillMaxWidth()
.height(56.dp)
.selectable(
selected = isCustomTime,
onClick = onCustomTimeClicked,
role = Role.RadioButton
)
.padding(horizontal = 24.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = isCustomTime, onClick = null)
Column(modifier = Modifier.padding(start = 16.dp)) {
Text(
text = stringResource(id = R.string.ScreenLockSettingsFragment__custom_time),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
if (isCustomTime && state.screenLockActivityTimeout > 0) {
Text(
text = ExpirationUtil.getExpirationDisplayValue(LocalContext.current, state.screenLockActivityTimeout.toInt()),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
@SignalPreview
@Composable
fun ScreenLockScreenPreview() {
Previews.Preview {
ScreenLockScreen(
state = ScreenLockSettingsState(true, 60),
onChecked = {},
onTimeClicked = {},
onCustomTimeClicked = {}
)
}
}

View File

@@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.components.settings.app.privacy.screenlock
/**
* Information about the screen lock state. Used in [ScreenLockSettingsViewModel].
*/
data class ScreenLockSettingsState(
val screenLock: Boolean = false,
val screenLockActivityTimeout: Long = 0L
)

View File

@@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.components.settings.app.privacy.screenlock
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Maintains the state of the [ScreenLockSettingsFragment]
*/
class ScreenLockSettingsViewModel : ViewModel() {
private val _state = MutableStateFlow(getState())
val state = _state.asStateFlow()
fun toggleScreenLock() {
val enabled = !_state.value.screenLock
SignalStore.settings.screenLockEnabled = enabled
_state.update {
it.copy(
screenLock = enabled
)
}
}
fun setScreenLockTimeout(seconds: Long) {
SignalStore.settings.screenLockTimeout = seconds
_state.update {
it.copy(
screenLockActivityTimeout = seconds
)
}
}
private fun getState(): ScreenLockSettingsState {
return ScreenLockSettingsState(
screenLock = SignalStore.settings.screenLockEnabled,
screenLockActivityTimeout = SignalStore.settings.screenLockTimeout
)
}
}

View File

@@ -105,9 +105,10 @@ class DSLConfiguration {
iconEnd: DSLSettingsIcon? = null,
isEnabled: Boolean = true,
onClick: () -> Unit,
onLongClick: (() -> Boolean)? = null
onLongClick: (() -> Boolean)? = null,
onDisabledClicked: () -> Unit = {}
) {
val preference = ClickPreference(title, summary, icon, iconEnd, isEnabled, onClick, onLongClick)
val preference = ClickPreference(title, summary, icon, iconEnd, isEnabled, onClick, onLongClick, onDisabledClicked)
children.add(preference)
}
@@ -344,7 +345,8 @@ class ClickPreference(
override val iconEnd: DSLSettingsIcon? = null,
override val isEnabled: Boolean = true,
val onClick: () -> Unit,
val onLongClick: (() -> Boolean)? = null
val onLongClick: (() -> Boolean)? = null,
val onDisabledClicked: () -> Unit = {}
) : PreferenceModel<ClickPreference>()
class LongClickPreference(