Add pin opt-out support to regV5.

This commit is contained in:
Greyson Parrelli 2026-06-17 14:03:29 -04:00 committed by jeffrey-signal
parent 5b99c6681c
commit 7b5f7cd808
No known key found for this signature in database
14 changed files with 221 additions and 21 deletions

View File

@ -218,6 +218,9 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
data.registrationLockEnabled,
data.accountEntropyPool.isNotEmpty()
)
} else if (data.pinOptedOut) {
Log.i(TAG, "[commitRegistrationData] User opted out of creating a PIN. Applying opt-out.")
SvrRepository.optOutOfPin(rotateAep = false)
}
Unit

View File

@ -87,6 +87,7 @@ import org.signal.registration.screens.phonenumber.PhoneNumberEntryScreenEvents
import org.signal.registration.screens.phonenumber.PhoneNumberEntryViewModel
import org.signal.registration.screens.phonenumber.PhoneNumberScreen
import org.signal.registration.screens.pincreation.PinCreationScreen
import org.signal.registration.screens.pincreation.PinCreationScreenEvents
import org.signal.registration.screens.pincreation.PinCreationViewModel
import org.signal.registration.screens.pinentry.PinEntryForRegistrationLockViewModel
import org.signal.registration.screens.pinentry.PinEntryForSmsBypassViewModel
@ -250,6 +251,7 @@ private const val COUNTRY_CODE_RESULT = "country_code_result"
private const val BACKUP_CREDENTIAL_RESULT = "backup_credential_result"
private const val LOCAL_BACKUP_RESTORE_RESULT = "local_backup_restore_result"
private const val PHONE_NUMBER_DISCOVERABILITY_RESULT = "phone_number_discoverability_result"
private const val PIN_LEARN_MORE_URL = "https://support.signal.org/hc/articles/360007059792"
/**
* Sets up the navigation graph for the registration flow using Navigation 3.
@ -584,10 +586,23 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
PinCreationScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
onEvent = { event ->
when (event) {
PinCreationScreenEvents.LearnMore -> {
LinkActions.openUrl(context, PIN_LEARN_MORE_URL) { error ->
when (error) {
OpenUrlError.NoBrowserFound -> Toast.makeText(context, R.string.LinkActions_error_no_browser_found, Toast.LENGTH_SHORT).show()
}
}
}
else -> viewModel.onEvent(event)
}
}
)
}

View File

@ -513,6 +513,26 @@ class RegistrationRepository(val context: Context, val networkController: Networ
result
}
/**
* Records that the user has chosen not to create a PIN.
*
* This does not perform the opt-out itself -- it simply notes the user's choice in the in-progress
* registration data and commits it. The app applies the actual opt-out (clearing PIN/registration lock
* state, refreshing attributes, etc.) when it persists the committed [org.signal.registration.proto.RegistrationData].
*
* Any previously-recorded PIN state is cleared so the persisted blob stays internally consistent.
*/
suspend fun setPinOptedOut(): Unit = withContext(Dispatchers.IO) {
Log.i(TAG, "[setPinOptedOut] Recording PIN opt-out in registration data.")
storageController.updateInProgressRegistrationData {
this.pinOptedOut = true
this.pin = ""
this.pinIsAlphanumeric = false
this.registrationLockEnabled = false
}
storageController.commitRegistrationData()
}
suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? {
return storageController.getPreExistingRegistrationData()
}

View File

@ -20,11 +20,13 @@ import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -35,7 +37,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.ImeAction
@ -47,12 +52,17 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.DropdownMenus
import org.signal.core.ui.compose.IconButtons.IconButton
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScaffold
import org.signal.registration.screens.TwoPaneRegistrationScaffold
import org.signal.registration.screens.attachDebugLogHelper
import org.signal.core.ui.R as CoreR
/**
* PIN creation screen for the registration flow.
@ -98,6 +108,7 @@ fun PinCreationScreen(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun OnePaneLayout(
params: RegistrationScaffold.Params.OnePane,
@ -110,15 +121,23 @@ private fun OnePaneLayout(
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
val topBarScrollBehavior = RegistrationScaffold.rememberTopBarScrollBehavior()
RegistrationScaffold(
modifier = modifier.fillMaxSize(),
topBar = {
PinCreationTopBar(
scrollBehavior = topBarScrollBehavior,
onEvent = onEvent
)
},
content = {
Column(
modifier = Modifier
.fillMaxSize()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.verticalScroll(scrollState)
.padding(params.panePadding(hasHeader = false))
.padding(params.panePadding(hasHeader = true))
) {
PinDescription(
isConfirmEnabled = state.isConfirmEnabled,
@ -158,6 +177,7 @@ private fun OnePaneLayout(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TwoPaneLayout(
params: RegistrationScaffold.Params.TwoPane,
@ -171,15 +191,23 @@ private fun TwoPaneLayout(
) {
val firstPaneScrollState = rememberScrollState()
val secondPaneScrollState = rememberScrollState()
val topBarScrollBehavior = RegistrationScaffold.rememberTopBarScrollBehavior()
TwoPaneRegistrationScaffold(
modifier = modifier.fillMaxSize(),
params = params,
topBar = {
PinCreationTopBar(
scrollBehavior = topBarScrollBehavior,
onEvent = onEvent
)
},
firstPane = { paddingValues ->
Column(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.verticalScroll(firstPaneScrollState)
.padding(paddingValues)
) {
@ -194,6 +222,7 @@ private fun TwoPaneLayout(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.verticalScroll(secondPaneScrollState)
.padding(paddingValues)
) {
@ -352,6 +381,71 @@ private fun KeyboardToggleButton(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PinCreationTopBar(
scrollBehavior: TopAppBarScrollBehavior,
onEvent: (PinCreationScreenEvents) -> Unit
) {
var showOptOutDialog by rememberSaveable { mutableStateOf(false) }
if (showOptOutDialog) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.PinCreationScreen__warning),
body = stringResource(R.string.PinCreationScreen__disable_pin_warning),
confirm = stringResource(R.string.PinCreationScreen__disable_pin),
dismiss = stringResource(R.string.PinCreationScreen__cancel),
onConfirm = {
showOptOutDialog = false
onEvent(PinCreationScreenEvents.OptOut)
},
onDismiss = { showOptOutDialog = false }
)
}
Scaffolds.DefaultTopAppBar(
title = "",
titleContent = { _, _ -> },
onNavigationClick = { },
navigationIcon = null,
scrollBehavior = scrollBehavior,
actions = {
val menuController = remember { DropdownMenus.MenuController() }
IconButton(
onClick = { menuController.show() },
modifier = Modifier.padding(horizontal = 8.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(CoreR.drawable.symbol_more_vertical_24),
contentDescription = stringResource(R.string.RegistrationActivity_open_menu)
)
}
DropdownMenus.Menu(
controller = menuController,
offsetX = 24.dp,
offsetY = 0.dp
) {
DropdownMenus.Item(
text = { Text(text = stringResource(R.string.PinCreationScreen__learn_more_about_pins)) },
onClick = {
menuController.hide()
onEvent(PinCreationScreenEvents.LearnMore)
}
)
DropdownMenus.Item(
text = { Text(text = stringResource(R.string.PinCreationScreen__disable_pin)) },
onClick = {
menuController.hide()
showOptOutDialog = true
}
)
}
}
)
}
@Composable
private fun NextButton(
params: RegistrationScaffold.Params,

View File

@ -11,4 +11,5 @@ sealed class PinCreationScreenEvents : DebugLoggableModel() {
data class PinSubmitted(val pin: String) : PinCreationScreenEvents()
data object ToggleKeyboard : PinCreationScreenEvents()
data object LearnMore : PinCreationScreenEvents()
data object OptOut : PinCreationScreenEvents()
}

View File

@ -65,12 +65,21 @@ class PinCreationViewModel(
}
is PinCreationScreenEvents.LearnMore -> {
// TODO [registration] - Show learn more dialog or navigate to help screen
throw NotImplementedError("Show learn more dialog or navigate to help screen")
// Handled by the navigation layer, which opens the help URL directly.
}
is PinCreationScreenEvents.OptOut -> {
_state.value = state.copy(isConfirmEnabled = false)
applyOptOut()
}
}
}
private suspend fun applyOptOut() {
Log.i(TAG, "[OptOut] User opted out of creating a PIN. Recording choice and completing registration.")
repository.setPinOptedOut()
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
}
@VisibleForTesting
fun applyParentState(state: PinCreationState, parentState: RegistrationFlowState): PinCreationState {
return state.copy(accountEntropyPool = parentState.accountEntropyPool)

View File

@ -184,8 +184,9 @@ class PinEntryForRegistrationLockViewModel(
}
private fun handleSkip() {
Log.d(TAG, "Skip requested - this will result in account data loss after timeRemaining: $timeRemaining ms")
// TODO [registration] - Show confirmation dialog warning about data loss, then proceed without PIN
// Registration lock is enforced server-side, so there's no way to register without the PIN. The skip option is
// never shown in this mode, so reaching here indicates a bug.
throw NotImplementedError("Skip is not a valid action during registration lock PIN entry")
}
class Factory(

View File

@ -143,9 +143,10 @@ class PinEntryForSvrRestoreViewModel(
}
}
private fun handleSkip() {
// TODO [registration] - Handle skip
throw NotImplementedError("Handle skip")
private suspend fun handleSkip() {
Log.i(TAG, "[Skip] User opted out of restoring data and creating a PIN. Recording choice and completing registration.")
repository.setPinOptedOut()
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
}
class Factory(

View File

@ -145,12 +145,14 @@ private fun OnePaneLayout(
)
}
SkipButton(
onSkip = { onEvent(PinEntryScreenEvents.Skip) },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(params.edgeInset)
)
if (state.mode != PinEntryState.Mode.RegistrationLock) {
SkipButton(
onSkip = { onEvent(PinEntryScreenEvents.Skip) },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(params.edgeInset)
)
}
}
},
footer = {
@ -222,12 +224,14 @@ private fun TwoPaneLayout(
)
}
SkipButton(
onSkip = { onEvent(PinEntryScreenEvents.Skip) },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(params.edgeInset)
)
if (state.mode != PinEntryState.Mode.RegistrationLock) {
SkipButton(
onSkip = { onEvent(PinEntryScreenEvents.Skip) },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(params.edgeInset)
)
}
}
},
footer = {

View File

@ -34,6 +34,10 @@ message RegistrationData {
bytes temporaryMasterKey = 17;
bool registrationLockEnabled = 18;
// Whether the user chose not to create a PIN during registration. The app is responsible for applying the
// actual opt-out (clearing PIN/registration lock state, refreshing attributes, etc.) when it commits this data.
bool pinOptedOut = 24;
// SVR credentials (from appendSvrCredentials / getRestoredSvrCredentials)
repeated SvrCredential svrCredentials = 19;

View File

@ -333,6 +333,16 @@
<string name="PinCreationScreen__pin_at_least_4_characters">PIN must be at least 4 characters</string>
<!-- Hint shown below the create PIN field when the user is confirming their new Signal PIN. -->
<string name="PinCreationScreen__reenter_pin">Re-enter PIN</string>
<!-- Overflow menu item that opens a help article about Signal PINs. -->
<string name="PinCreationScreen__learn_more_about_pins">Learn more about PINs</string>
<!-- Title of the dialog confirming that the user wants to disable their Signal PIN. -->
<string name="PinCreationScreen__warning">Warning</string>
<!-- Warning shown in the dialog confirming that the user wants to disable their Signal PIN. -->
<string name="PinCreationScreen__disable_pin_warning">If you disable the PIN, you will lose all data when you re-register Signal unless you manually back up and restore. You cannot turn on Registration Lock while the PIN is disabled.</string>
<!-- Overflow menu item and dialog button that disables the Signal PIN. -->
<string name="PinCreationScreen__disable_pin">Disable PIN</string>
<!-- Labels the button that cancels disabling the Signal PIN. -->
<string name="PinCreationScreen__cancel">Cancel</string>
<!-- PIN entry screen title when registration lock is active. -->
<string name="PinEntryScreen__registration_lock">Registration Lock</string>

View File

@ -103,6 +103,19 @@ class PinCreationViewModelTest {
assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
}
// ==================== OptOut Tests ====================
@Test
fun `OptOut records opt-out and completes registration`() = runTest(testDispatcher) {
val initialState = PinCreationState(accountEntropyPool = AccountEntropyPool.generate())
viewModel.applyEvent(initialState, PinCreationScreenEvents.OptOut)
coVerify { mockRepository.setPinOptedOut() }
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.RegistrationComplete)
}
// ==================== applyParentState Tests ====================
@Test

View File

@ -5,6 +5,7 @@
package org.signal.registration.screens.pinentry
import assertk.assertFailure
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
@ -306,6 +307,17 @@ class PinEntryForRegistrationLockViewModelTest {
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError)
}
// ==================== Skip Tests ====================
@Test
fun `Skip throws because skipping is not valid during registration lock`() = runTest {
val initialState = PinEntryState(mode = PinEntryState.Mode.RegistrationLock)
assertFailure {
viewModel.applyEvent(initialState, PinEntryScreenEvents.Skip, parentEventEmitter, stateEmitter)
}.isInstanceOf<NotImplementedError>()
}
// ==================== ToggleKeyboard Tests ====================
@Test

View File

@ -222,6 +222,19 @@ class PinEntryForSvrRestoreViewModelTest {
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PinEntryState.OneTimeEvent.UnknownError)
}
// ==================== Skip Tests ====================
@Test
fun `Skip records PIN opt-out and completes registration`() = runTest {
val initialState = PinEntryState(mode = PinEntryState.Mode.SvrRestore)
viewModel.applyEvent(initialState, PinEntryScreenEvents.Skip, parentEventEmitter, stateEmitter)
coVerify { mockRepository.setPinOptedOut() }
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.RegistrationComplete)
}
// ==================== ToggleKeyboard Tests ====================
@Test