diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinPreferenceFragment.java deleted file mode 100644 index 8d93783121..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinPreferenceFragment.java +++ /dev/null @@ -1,105 +0,0 @@ -package org.thoughtcrime.securesms.preferences; - -import android.content.Intent; -import android.os.Bundle; - -import androidx.annotation.Nullable; -import androidx.preference.Preference; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.snackbar.Snackbar; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity; -import org.thoughtcrime.securesms.payments.backup.PaymentsRecoveryStartFragmentArgs; -import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity; -import org.thoughtcrime.securesms.pin.PinOptOutDialog; - -public class AdvancedPinPreferenceFragment extends ListSummaryPreferenceFragment { - - private static final String PREF_ENABLE = "pref_pin_enable"; - private static final String PREF_DISABLE = "pref_pin_disable"; - - @Override - public void onCreate(Bundle paramBundle) { - super.onCreate(paramBundle); - } - - @Override - public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { - addPreferencesFromResource(R.xml.preferences_advanced_pin); - } - - @Override - public void onResume() { - super.onResume(); - updatePreferenceState(); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - if (requestCode == CreateSvrPinActivity.REQUEST_NEW_PIN && resultCode == CreateSvrPinActivity.RESULT_OK) { - Snackbar.make(requireView(), R.string.ApplicationPreferencesActivity_pin_created, Snackbar.LENGTH_LONG).show(); - } - } - - private void updatePreferenceState() { - Preference enable = this.findPreference(PREF_ENABLE); - Preference disable = this.findPreference(PREF_DISABLE); - - if (SignalStore.svr().hasOptedOut()) { - enable.setVisible(true); - disable.setVisible(false); - - enable.setOnPreferenceClickListener(preference -> { - onPreferenceChanged(true); - return true; - }); - } else { - enable.setVisible(false); - disable.setVisible(true); - - disable.setOnPreferenceClickListener(preference -> { - onPreferenceChanged(false); - return true; - }); - } - } - - private void onPreferenceChanged(boolean enabled) { - boolean hasRegistrationLock = SignalStore.svr().isRegistrationLockEnabled(); - - if (!enabled && hasRegistrationLock) { - new MaterialAlertDialogBuilder(requireContext()) - .setMessage(R.string.ApplicationPreferencesActivity_pins_are_required_for_registration_lock) - .setCancelable(true) - .setPositiveButton(android.R.string.ok, (d, which) -> d.dismiss()) - .show(); - } else if (!enabled && SignalStore.payments().mobileCoinPaymentsEnabled() && !SignalStore.payments().getUserConfirmedMnemonic()) { - new MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.ApplicationPreferencesActivity_record_payments_recovery_phrase) - .setMessage(R.string.ApplicationPreferencesActivity_before_you_can_disable_your_pin) - .setPositiveButton(R.string.ApplicationPreferencesActivity_record_phrase, (dialog, which) -> { - Intent intent = new Intent(requireContext(), PaymentsActivity.class); - intent.putExtra(PaymentsActivity.EXTRA_PAYMENTS_STARTING_ACTION, R.id.action_directly_to_paymentsBackup); - intent.putExtra(PaymentsActivity.EXTRA_STARTING_ARGUMENTS, new PaymentsRecoveryStartFragmentArgs.Builder().setFinishOnConfirm(true).build().toBundle()); - - startActivity(intent); - - dialog.dismiss(); - }) - .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss()) - .setCancelable(true) - .show(); - } else if (!enabled) { - PinOptOutDialog.show(requireContext(), - () -> { - updatePreferenceState(); - Snackbar.make(requireView(), R.string.ApplicationPreferencesActivity_pin_disabled, Snackbar.LENGTH_SHORT).show(); - }); - } else { - startActivityForResult(CreateSvrPinActivity.getIntentForPinCreate(requireContext()), CreateSvrPinActivity.REQUEST_NEW_PIN); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinSettingsFragment.kt new file mode 100644 index 0000000000..79a2ef9100 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinSettingsFragment.kt @@ -0,0 +1,228 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.preferences + +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.signal.core.ui.compose.Dialogs +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Rows +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalPreview +import org.signal.core.ui.compose.Snackbars +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity +import org.thoughtcrime.securesms.payments.backup.PaymentsRecoveryStartFragmentArgs.Builder +import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity +import org.thoughtcrime.securesms.pin.PinOptOutDialog + +/** + * Fragment which allows user to enable or disable their PIN + */ +class AdvancedPinSettingsFragment : ComposeFragment() { + + private val viewModel: AdvancedPinSettingsViewModel by viewModels() + + override fun onResume() { + super.onResume() + viewModel.refresh() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.event.collectLatest { + when (it) { + AdvancedPinSettingsViewModel.Event.SHOW_OPT_OUT_DIALOG -> PinOptOutDialog.show(requireContext()) { + viewModel.onPinOptOutSuccess() + displayOptOutSnackbar() + } + AdvancedPinSettingsViewModel.Event.LAUNCH_PIN_CREATION_FLOW -> { + startActivityForResult(CreateSvrPinActivity.getIntentForPinCreate(requireContext()), CreateSvrPinActivity.REQUEST_NEW_PIN) + } + AdvancedPinSettingsViewModel.Event.LAUNCH_RECOVERY_PHRASE_HANDLING -> { + val intent = Intent(requireContext(), PaymentsActivity::class.java) + intent.putExtra(PaymentsActivity.EXTRA_PAYMENTS_STARTING_ACTION, R.id.action_directly_to_paymentsBackup) + intent.putExtra(PaymentsActivity.EXTRA_STARTING_ARGUMENTS, Builder().setFinishOnConfirm(true).build().toBundle()) + + startActivity(intent) + } + } + } + } + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == CreateSvrPinActivity.REQUEST_NEW_PIN && resultCode == CreateSvrPinActivity.RESULT_OK) { + Snackbar.make(requireView(), R.string.ApplicationPreferencesActivity_pin_created, Snackbar.LENGTH_LONG).show() + } + } + + @Composable + override fun FragmentContent() { + val hasOptedOutOfPin: Boolean by viewModel.hasOptedOutOfPin.collectAsStateWithLifecycle() + + AdvancedPinSettingsFragmentContent( + hasOptedOutOfPin = hasOptedOutOfPin, + onNavigationClick = { requireActivity().onBackPressedDispatcher.onBackPressed() }, + snackbarHostState = viewModel.snackbarHostState, + onOptionClick = viewModel::setOptOut + ) + + val dialog: AdvancedPinSettingsViewModel.Dialog by viewModel.dialog.collectAsStateWithLifecycle() + + when (dialog) { + AdvancedPinSettingsViewModel.Dialog.REGISTRATION_LOCK -> PinsAreRequiredForRegistrationLockDialog( + onDismiss = { + viewModel.dismissDialog() + } + ) + AdvancedPinSettingsViewModel.Dialog.RECORD_PAYMENTS_RECOVERY_PHRASE -> RecordPaymentsRecoveryPhraseDialog( + onConfirm = { + viewModel.launchRecoveryPhraseHandling() + }, + onDismiss = { + viewModel.dismissDialog() + } + ) + else -> Unit + } + } + + private fun displayOptOutSnackbar() { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.snackbarHostState.showSnackbar( + message = getString(R.string.ApplicationPreferencesActivity_pin_disabled), + duration = SnackbarDuration.Long + ) + } + } +} + +@Composable +private fun AdvancedPinSettingsFragmentContent( + hasOptedOutOfPin: Boolean, + onNavigationClick: () -> Unit = {}, + onOptionClick: (Boolean) -> Unit = {}, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } +) { + Scaffolds.Settings( + title = stringResource(R.string.preferences__advanced_pin_settings_title), + navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24), + navigationContentDescription = stringResource(R.string.CallScreenTopBar__go_back), + onNavigationClick = onNavigationClick, + snackbarHost = { + Snackbars.Host( + snackbarHostState = snackbarHostState + ) + } + ) { + val listener: () -> Unit = remember(onOptionClick, hasOptedOutOfPin) { + { + onOptionClick(hasOptedOutOfPin) + } + } + + if (hasOptedOutOfPin) { + Rows.TextRow( + text = stringResource(R.string.preferences__enable_pin), + label = stringResource(R.string.preferences__pins_keep_information_stored_with_signal_encrypted_so_only_you_can_access_it), + onClick = listener, + modifier = Modifier.padding(it) + ) + } else { + Rows.TextRow( + text = stringResource(R.string.preferences__disable_pin), + label = stringResource(R.string.preferences__if_you_disable_the_pin_you_will_lose_all_data), + onClick = listener, + modifier = Modifier.padding(it) + ) + } + } +} + +@Composable +private fun PinsAreRequiredForRegistrationLockDialog( + onDismiss: () -> Unit = {} +) { + Dialogs.SimpleMessageDialog( + message = stringResource(R.string.ApplicationPreferencesActivity_pins_are_required_for_registration_lock), + dismiss = stringResource(android.R.string.ok), + onDismiss = onDismiss + ) +} + +@Composable +private fun RecordPaymentsRecoveryPhraseDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.ApplicationPreferencesActivity_record_payments_recovery_phrase), + body = stringResource(R.string.ApplicationPreferencesActivity_before_you_can_disable_your_pin), + confirm = stringResource(R.string.ApplicationPreferencesActivity_record_phrase), + onConfirm = onConfirm, + dismiss = stringResource(android.R.string.cancel), + onDismiss = onDismiss + ) +} + +@SignalPreview +@Composable +private fun AdvancedPinSettingsFragmentContentEnabledPreview() { + Previews.Preview { + AdvancedPinSettingsFragmentContent( + hasOptedOutOfPin = false + ) + } +} + +@SignalPreview +@Composable +private fun AdvancedPinSettingsFragmentContentDisabledPreview() { + Previews.Preview { + AdvancedPinSettingsFragmentContent( + hasOptedOutOfPin = true + ) + } +} + +@SignalPreview +@Composable +private fun PinsAreRequiredForRegistrationLockDialogPreview() { + Previews.Preview { + PinsAreRequiredForRegistrationLockDialog() + } +} + +@SignalPreview +@Composable +private fun RecordPaymentsRecoveryPhraseDialogPreview() { + Previews.Preview { + RecordPaymentsRecoveryPhraseDialog({}, {}) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinSettingsViewModel.kt new file mode 100644 index 0000000000..93dd3188e7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinSettingsViewModel.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.preferences + +import androidx.compose.material3.SnackbarHostState +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.thoughtcrime.securesms.keyvalue.SignalStore + +class AdvancedPinSettingsViewModel : ViewModel() { + + enum class Dialog { + NONE, + REGISTRATION_LOCK, + RECORD_PAYMENTS_RECOVERY_PHRASE + } + + enum class Event { + SHOW_OPT_OUT_DIALOG, + LAUNCH_PIN_CREATION_FLOW, + LAUNCH_RECOVERY_PHRASE_HANDLING + } + + private val internalDialog = MutableStateFlow(Dialog.NONE) + private val internalEvent = MutableSharedFlow() + private val internalHasOptedOutOfPin = MutableStateFlow(SignalStore.svr.hasOptedOut()) + + val dialog: StateFlow = internalDialog + val event: SharedFlow = internalEvent + val hasOptedOutOfPin: StateFlow = internalHasOptedOutOfPin + val snackbarHostState = SnackbarHostState() + + fun refresh() { + internalHasOptedOutOfPin.value = SignalStore.svr.hasOptedOut() + } + + fun setOptOut(enabled: Boolean) { + val hasRegistrationLock = SignalStore.svr.isRegistrationLockEnabled + + when { + !enabled && hasRegistrationLock -> { + internalDialog.value = Dialog.REGISTRATION_LOCK + } + !enabled && SignalStore.payments.mobileCoinPaymentsEnabled() && !SignalStore.payments.userConfirmedMnemonic -> { + internalDialog.value = Dialog.RECORD_PAYMENTS_RECOVERY_PHRASE + } + !enabled -> { + dismissDialog() + emitEvent(Event.SHOW_OPT_OUT_DIALOG) + } + else -> { + dismissDialog() + emitEvent(Event.LAUNCH_PIN_CREATION_FLOW) + } + } + } + + fun launchRecoveryPhraseHandling() { + emitEvent(Event.LAUNCH_RECOVERY_PHRASE_HANDLING) + } + + fun onPinOptOutSuccess() { + internalHasOptedOutOfPin.value = SignalStore.svr.hasOptedOut() + } + + fun dismissDialog() { + internalDialog.value = Dialog.NONE + } + + private fun emitEvent(event: Event) { + viewModelScope.launch { + internalEvent.emit(event) + } + } +} diff --git a/app/src/main/res/navigation/app_settings_with_change_number.xml b/app/src/main/res/navigation/app_settings_with_change_number.xml index 561112e7e6..4f9834bfba 100644 --- a/app/src/main/res/navigation/app_settings_with_change_number.xml +++ b/app/src/main/res/navigation/app_settings_with_change_number.xml @@ -208,7 +208,7 @@ - - - - - - - -