Update registration for new restore flows.

This commit is contained in:
Cody Henthorne 2024-11-07 10:42:54 -05:00 committed by Greyson Parrelli
parent aad2624bd5
commit 22c4e2d084
140 changed files with 8364 additions and 2679 deletions

View file

@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil
import org.thoughtcrime.securesms.registration.data.RegistrationData
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
@ -104,7 +105,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
pniRegistrationId = RegistrationRepository.getPniRegistrationId(),
recoveryPassword = "asdfasdfasdfasdf"
)
val remoteResult = RegistrationRepository.AccountRegistrationResult(
val remoteResult = AccountRegistrationResult(
uuid = UUID.randomUUID().toString(),
pni = UUID.randomUUID().toString(),
storageCapable = false,

View file

@ -838,6 +838,11 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".registrationv3.olddevice.TransferAccountActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:exported="false"/>
<activity android:name=".registration.ui.RegistrationActivity"
android:launchMode="singleTask"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
@ -845,6 +850,13 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".registrationv3.ui.RegistrationActivity"
android:launchMode="singleTask"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateHidden|adjustResize"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".restore.RestoreActivity"
android:launchMode="singleTask"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
@ -977,7 +989,7 @@
android:windowSoftInputMode="stateVisible|adjustResize"
android:exported="false"/>
<activity android:name=".registration.ui.restore.RemoteRestoreActivity"
<activity android:name=".registrationv3.ui.restore.RemoteRestoreActivity"
android:theme="@style/Signal.DayNight.NoActionBar"
android:exported="false"/>

View file

@ -191,11 +191,10 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private boolean userMustCreateSignalPin() {
return !SignalStore.registration().isRegistrationComplete() && !SignalStore.svr().hasPin() && !SignalStore.svr().lastPinCreateFailed() && !SignalStore.svr().hasOptedOut();
}
private boolean userHasSkippedOrForgottenPin() {
return !SignalStore.registration().isRegistrationComplete() && !SignalStore.svr().hasPin() && !SignalStore.svr().hasOptedOut() && SignalStore.svr().isPinForgottenOrSkipped();
return !SignalStore.registration().isRegistrationComplete() &&
!SignalStore.svr().hasOptedInWithAccess() &&
!SignalStore.svr().lastPinCreateFailed() &&
!SignalStore.svr().hasOptedOut();
}
private boolean userMustSetProfileName() {
@ -240,7 +239,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getTransferOrRestoreIntent() {
Intent intent = RestoreActivity.getIntentForTransferOrRestore(this);
Intent intent = RestoreActivity.getRestoreIntent(this);
return getRoutedIntent(intent, MainActivity.clearTop(this));
}

View file

@ -13,6 +13,7 @@ import org.greenrobot.eventbus.EventBus
import org.signal.core.util.Base64
import org.signal.core.util.EventTimer
import org.signal.core.util.Stopwatch
import org.signal.core.util.bytes
import org.signal.core.util.concurrent.LimitedWorker
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.forceForeignKeyConstraintsEnabled
@ -644,7 +645,7 @@ object BackupRepository {
else -> Log.w(TAG, "Unrecognized frame")
}
EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_RESTORE, frameReader.getBytesRead(), totalLength))
EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_RESTORE, frameReader.getBytesRead().bytes, totalLength.bytes))
}
if (chatItemInserter.flush()) {
@ -1176,7 +1177,7 @@ object BackupRepository {
return if (SignalStore.backup.backupsInitialized) {
getArchiveServiceAccessPair().runOnStatusCodeError(resetInitializedStateErrorAction)
} else if (isPreRestoreDuringRegistration()) {
Log.w(TAG, "Requesting/using auth credentials in pre-restore state")
Log.w(TAG, "Requesting/using auth credentials in pre-restore state", Throwable())
getArchiveServiceAccessPair()
} else {
val messageBackupKey = SignalStore.backup.messageBackupKey

View file

@ -5,18 +5,19 @@
package org.thoughtcrime.securesms.backup.v2
class RestoreV2Event(val type: Type, val count: Long, val estimatedTotalCount: Long) {
import org.signal.core.util.ByteSize
class RestoreV2Event(val type: Type, val count: ByteSize, val estimatedTotalCount: ByteSize) {
enum class Type {
PROGRESS_DOWNLOAD,
PROGRESS_RESTORE,
PROGRESS_MEDIA_RESTORE,
FINISHED
}
fun getProgress(): Float {
if (estimatedTotalCount == 0L) {
if (estimatedTotalCount.inWholeBytes == 0L) {
return 0f
}
return count.toFloat() / estimatedTotalCount.toFloat()
return count.inWholeBytes.toFloat() / estimatedTotalCount.inWholeBytes.toFloat()
}
}

View file

@ -1,98 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.BackupRestoreJob
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.jobs.SyncArchivedMediaJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import java.io.InputStream
import kotlin.time.Duration.Companion.seconds
class RemoteRestoreViewModel : ViewModel() {
val disposables = CompositeDisposable()
private val _state: MutableState<ScreenState> = mutableStateOf(
ScreenState(
backupTier = SignalStore.backup.backupTier,
backupTime = SignalStore.backup.lastBackupTime,
importState = ImportState.NONE,
restoreProgress = null
)
)
val state: State<ScreenState> = _state
fun import(length: Long, inputStreamFactory: () -> InputStream) {
_state.value = _state.value.copy(importState = ImportState.IN_PROGRESS)
val self = Recipient.self()
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
disposables += Single.fromCallable { BackupRepository.import(length, inputStreamFactory, selfData) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
_state.value = _state.value.copy(importState = ImportState.NONE)
}
}
fun restore() {
_state.value = _state.value.copy(importState = ImportState.IN_PROGRESS)
disposables += Single.fromCallable {
AppDependencies
.jobManager
.startChain(BackupRestoreJob())
.then(SyncArchivedMediaJob())
.then(BackupRestoreMediaJob())
.enqueueAndBlockUntilCompletion(120.seconds.inWholeMilliseconds)
RegistrationUtil.maybeMarkRegistrationComplete()
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
_state.value = _state.value.copy(importState = ImportState.RESTORED)
}
}
fun updateRestoreProgress(restoreEvent: RestoreV2Event) {
_state.value = _state.value.copy(restoreProgress = restoreEvent)
}
override fun onCleared() {
disposables.clear()
}
data class ScreenState(
val backupTier: MessageBackupTier?,
val backupTime: Long,
val importState: ImportState,
val restoreProgress: RestoreV2Event?
)
enum class ImportState(val inProgress: Boolean = false) {
NONE,
IN_PROGRESS(true),
RESTORED
}
}

View file

@ -69,10 +69,10 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
@Suppress("DEPRECATION")
clickPref(
title = DSLSettingsText.from(if (state.hasPin) R.string.preferences_app_protection__change_your_pin else R.string.preferences_app_protection__create_a_pin),
title = DSLSettingsText.from(if (state.hasOptedInWithAccess) R.string.preferences_app_protection__change_your_pin else R.string.preferences_app_protection__create_a_pin),
isEnabled = state.isDeprecatedOrUnregistered(),
onClick = {
if (state.hasPin) {
if (state.hasOptedInWithAccess) {
startActivityForResult(CreateSvrPinActivity.getIntentForPinChangeFromSettings(requireContext()), CreateSvrPinActivity.REQUEST_NEW_PIN)
} else {
startActivityForResult(CreateSvrPinActivity.getIntentForPinCreate(requireContext()), CreateSvrPinActivity.REQUEST_NEW_PIN)
@ -94,7 +94,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
title = DSLSettingsText.from(R.string.preferences_app_protection__registration_lock),
summary = DSLSettingsText.from(R.string.AccountSettingsFragment__require_your_signal_pin),
isChecked = state.registrationLockEnabled,
isEnabled = state.hasPin && state.isDeprecatedOrUnregistered(),
isEnabled = (state.hasOptedInWithAccess) && state.isDeprecatedOrUnregistered(),
onClick = {
setRegistrationLockEnabled(!state.registrationLockEnabled)
}
@ -125,7 +125,6 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
clickPref(
title = DSLSettingsText.from(R.string.preferences_chats__transfer_account),
summary = DSLSettingsText.from(R.string.preferences_chats__transfer_account_to_a_new_android_device),
isEnabled = state.isDeprecatedOrUnregistered(),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_oldDeviceTransferActivity)
}

View file

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.account
data class AccountSettingsState(
val hasPin: Boolean,
val hasOptedInWithAccess: Boolean,
val pinRemindersEnabled: Boolean,
val registrationLockEnabled: Boolean,
val userUnregistered: Boolean,

View file

@ -19,7 +19,8 @@ class AccountSettingsViewModel : ViewModel() {
private fun getCurrentState(): AccountSettingsState {
return AccountSettingsState(
hasPin = SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut(),
pinRemindersEnabled = SignalStore.pin.arePinRemindersEnabled(),
hasOptedInWithAccess = SignalStore.svr.hasOptedInWithAccess(),
pinRemindersEnabled = SignalStore.pin.arePinRemindersEnabled() && SignalStore.svr.hasPin(),
registrationLockEnabled = SignalStore.svr.isRegistrationLockEnabled,
userUnregistered = TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application),
clientDeprecated = SignalStore.misc.isClientDeprecated

View file

@ -392,7 +392,7 @@ class ChangeNumberViewModel : ViewModel() {
private suspend fun changeNumberWithRecoveryPassword(): Boolean {
Log.v(TAG, "changeNumberWithRecoveryPassword()")
SignalStore.svr.recoveryPassword?.let { recoveryPassword ->
if (SignalStore.svr.hasPin()) {
if (SignalStore.svr.hasOptedInWithAccess()) {
val result = repository.changeNumberWithRecoveryPassword(recoveryPassword = recoveryPassword, newE164 = number.e164Number)
if (result is ChangeNumberResult.Success) {

View file

@ -837,7 +837,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
SignalStore.account.setRegistered(false)
SignalStore.registration.clearRegistrationComplete()
SignalStore.registration.clearHasUploadedProfile()
SignalStore.registration.clearSkippedTransferOrRestore()
SignalStore.registration.debugClearSkippedTransferOrRestore()
Toast.makeText(context, "Unregistered!", Toast.LENGTH_SHORT).show()
}

View file

@ -63,13 +63,17 @@ fun DrawScope.drawQr(
val deadzonePaddingPercent = 0.045f
// We want an even number of dots on either side of the deadzone
val deadzoneRadius: Int = (data.height * (deadzonePercent + deadzonePaddingPercent)).toInt().let { candidateDeadzoneHeight ->
if ((data.height - candidateDeadzoneHeight) % 2 == 0) {
candidateDeadzoneHeight
} else {
candidateDeadzoneHeight + 1
}
} / 2
val deadzoneRadius: Int = if (data.canSupportIconOverlay) {
(data.height * (deadzonePercent + deadzonePaddingPercent)).toInt().let { candidateDeadzoneHeight ->
if ((data.height - candidateDeadzoneHeight) % 2 == 0) {
candidateDeadzoneHeight
} else {
candidateDeadzoneHeight + 1
}
} / 2
} else {
0
}
val cellWidthPx: Float = size.width / data.width
val cornerRadius = CornerRadius(7f, 7f)
@ -108,25 +112,27 @@ fun DrawScope.drawQr(
}
}
// Logo border
val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2
drawCircle(
color = foregroundColor,
radius = logoBorderRadiusPx,
style = Stroke(width = cellWidthPx * 0.75f),
center = this.center
)
// Logo
val logoWidthPx = (((deadzonePercent - deadzonePaddingPercent) * 0.6f) * size.width).toInt()
val logoOffsetPx = ((size.width - logoWidthPx) / 2).toInt()
if (logo != null) {
drawImage(
image = logo,
dstOffset = IntOffset(logoOffsetPx, logoOffsetPx),
dstSize = IntSize(logoWidthPx, logoWidthPx),
colorFilter = ColorFilter.tint(foregroundColor)
if (data.canSupportIconOverlay) {
// Logo border
val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2
drawCircle(
color = foregroundColor,
radius = logoBorderRadiusPx,
style = Stroke(width = cellWidthPx * 0.75f),
center = this.center
)
// Logo
val logoWidthPx = (((deadzonePercent - deadzonePaddingPercent) * 0.6f) * size.width).toInt()
val logoOffsetPx = ((size.width - logoWidthPx) / 2).toInt()
if (logo != null) {
drawImage(
image = logo,
dstOffset = IntOffset(logoOffsetPx, logoOffsetPx),
dstSize = IntSize(logoWidthPx, logoWidthPx),
colorFilter = ColorFilter.tint(foregroundColor)
)
}
}
}
@ -135,7 +141,7 @@ fun DrawScope.drawQr(
private fun Preview() {
Surface {
QrCode(
data = QrCodeData.forData("https://signal.org", 64),
data = QrCodeData.forData("https://signal.org"),
modifier = Modifier.size(350.dp)
)
}

View file

@ -24,7 +24,6 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -170,13 +169,13 @@ private fun PreviewWithCodeShort() {
Surface {
Column {
QrCodeBadge(
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
data = QrCodeState.Present(QrCodeData.forData("https://signal.org")),
colorScheme = UsernameQrCodeColorScheme.Blue,
username = "parker.42",
usernameCopyable = false
)
QrCodeBadge(
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
data = QrCodeState.Present(QrCodeData.forData("https://signal.org")),
colorScheme = UsernameQrCodeColorScheme.Blue,
username = "parker.42",
usernameCopyable = true
@ -193,14 +192,14 @@ private fun PreviewWithCodeLong() {
Surface {
Column {
QrCodeBadge(
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
data = QrCodeState.Present(QrCodeData.forData("https://signal.org")),
colorScheme = UsernameQrCodeColorScheme.Blue,
username = "TheAmazingSpiderMan.42",
usernameCopyable = false
)
Spacer(modifier = Modifier.height(8.dp))
QrCodeBadge(
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
data = QrCodeState.Present(QrCodeData.forData("https://signal.org")),
colorScheme = UsernameQrCodeColorScheme.Blue,
username = "TheAmazingSpiderMan.42",
usernameCopyable = true
@ -249,7 +248,7 @@ private fun PreviewAllColorsP2() {
@Composable
private fun SampleCode(colorScheme: UsernameQrCodeColorScheme) {
QrCodeBadge(
data = QrCodeState.Present(QrCodeData.forData("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdf", 64)),
data = QrCodeState.Present(QrCodeData.forData("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdf")),
colorScheme = colorScheme,
username = "parker.42"
)

View file

@ -15,6 +15,7 @@ import java.util.BitSet
class QrCodeData(
val width: Int,
val height: Int,
val canSupportIconOverlay: Boolean,
private val bits: BitSet
) {
@ -34,13 +35,17 @@ class QrCodeData(
/**
* Converts the provided string data into a QR representation.
*
* @param supportIconOverlay indicates data can be rendered with the icon overlay. Rendering with an icon relies on more error correction
* data in the QR which requires a denser rendering which is sometimes not easily scanned by our scanner. Set to false if data is expected to be
* long to prevent scanning issues.
*/
@WorkerThread
fun forData(data: String, size: Int): QrCodeData {
fun forData(data: String, supportIconOverlay: Boolean = true): QrCodeData {
val qrCodeWriter = QRCodeWriter()
val hints = mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q.toString())
val hints = mapOf(EncodeHintType.ERROR_CORRECTION to if (supportIconOverlay) ErrorCorrectionLevel.Q.toString() else ErrorCorrectionLevel.L.toString())
val padded = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, size, size, hints)
val padded = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, 64, 64, hints)
val dimens = padded.enclosingRectangle
val xStart = dimens[0]
val yStart = dimens[1]
@ -58,7 +63,7 @@ class QrCodeData(
}
}
return QrCodeData(width, height, bitSet)
return QrCodeData(width, height, supportIconOverlay, bitSet)
}
}
}

View file

@ -39,7 +39,7 @@ class UsernameLinkQrColorPickerViewModel : ViewModel() {
if (usernameLink != null) {
disposable += Single
.fromCallable { QrCodeData.forData(usernameLink.toLink(), 64) }
.fromCallable { QrCodeData.forData(usernameLink.toLink()) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { qrData ->

View file

@ -371,7 +371,7 @@ private fun MainScreenPreview() {
activeTab = ActiveTab.Code,
username = "PeterParker.42",
usernameLinkState = UsernameLinkState.Present("https://signal.org"),
qrCodeState = QrCodeState.Present(QrCodeData.forData("PeterParker.42", 64)),
qrCodeState = QrCodeState.Present(QrCodeData.forData("PeterParker.42")),
qrCodeColorScheme = UsernameQrCodeColorScheme.Orange
)
)

View file

@ -203,7 +203,7 @@ class UsernameLinkSettingsViewModel : ViewModel() {
private fun generateQrCodeData(url: Optional<String>): Single<Optional<QrCodeData>> {
return Single.fromCallable {
url.map { QrCodeData.forData(it, 64) }
url.map { QrCodeData.forData(it) }
}
}

View file

@ -334,7 +334,7 @@ private fun previewState(): UsernameLinkSettingsState {
activeTab = ActiveTab.Code,
username = "parker.42",
usernameLinkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"),
qrCodeState = QrCodeState.Present(QrCodeData.forData(link, 64)),
qrCodeState = QrCodeState.Present(QrCodeData.forData(link)),
qrCodeColorScheme = UsernameQrCodeColorScheme.Blue
)
}

View file

@ -1,170 +0,0 @@
package org.thoughtcrime.securesms.devicetransfer;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.devicetransfer.DeviceToDeviceTransferService;
import org.signal.devicetransfer.TransferStatus;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
/**
* Drives the UI for the actual device transfer progress. Shown after setup is complete
* and the two devices are transferring.
* <p>
* Handles show progress and error state.
*/
public abstract class DeviceTransferFragment extends LoggingFragment {
private static final String TRANSFER_FINISHED_KEY = "transfer_finished";
private final OnBackPressed onBackPressed = new OnBackPressed();
private final TransferModeListener transferModeListener = new TransferModeListener();
protected TextView title;
protected View tryAgain;
protected Button cancel;
protected View progress;
protected View alert;
protected TextView status;
protected boolean transferFinished;
public DeviceTransferFragment() {
super(R.layout.fragment_device_transfer);
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (savedInstanceState != null) {
transferFinished = savedInstanceState.getBoolean(TRANSFER_FINISHED_KEY);
}
}
@Override
public void onStart() {
super.onStart();
if (transferFinished) {
navigateToTransferComplete();
}
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
outState.putBoolean(TRANSFER_FINISHED_KEY, transferFinished);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
title = view.findViewById(R.id.device_transfer_fragment_title);
tryAgain = view.findViewById(R.id.device_transfer_fragment_try_again);
cancel = view.findViewById(R.id.device_transfer_fragment_cancel);
progress = view.findViewById(R.id.device_transfer_fragment_progress);
alert = view.findViewById(R.id.device_transfer_fragment_alert);
status = view.findViewById(R.id.device_transfer_fragment_status);
cancel.setOnClickListener(v -> cancelActiveTransfer());
tryAgain.setOnClickListener(v -> {
EventBus.getDefault().unregister(transferModeListener);
EventBus.getDefault().removeStickyEvent(TransferStatus.class);
navigateToRestartTransfer();
});
EventBus.getDefault().register(transferModeListener);
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), onBackPressed);
}
@Override
public void onDestroyView() {
EventBus.getDefault().unregister(transferModeListener);
super.onDestroyView();
}
private void cancelActiveTransfer() {
new MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.DeviceTransfer__stop_transfer)
.setMessage(R.string.DeviceTransfer__all_transfer_progress_will_be_lost)
.setPositiveButton(R.string.DeviceTransfer__stop_transfer, (d, w) -> {
EventBus.getDefault().unregister(transferModeListener);
DeviceToDeviceTransferService.stop(requireContext());
EventBus.getDefault().removeStickyEvent(TransferStatus.class);
navigateAwayFromTransfer();
})
.setNegativeButton(android.R.string.cancel, null)
.show();
}
protected void ignoreTransferStatusEvents() {
EventBus.getDefault().unregister(transferModeListener);
}
protected abstract void navigateToRestartTransfer();
protected abstract void navigateAwayFromTransfer();
protected abstract void navigateToTransferComplete();
private class TransferModeListener {
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
public void onEventMainThread(@NonNull TransferStatus event) {
if (event.getTransferMode() != TransferStatus.TransferMode.SERVICE_CONNECTED) {
abort();
}
}
}
protected void abort() {
abort(R.string.DeviceTransfer__transfer_failed);
}
protected void abort(@StringRes int errorMessage) {
EventBus.getDefault().unregister(transferModeListener);
DeviceToDeviceTransferService.stop(requireContext());
progress.setVisibility(View.GONE);
alert.setVisibility(View.VISIBLE);
tryAgain.setVisibility(View.VISIBLE);
title.setText(R.string.DeviceTransfer__unable_to_transfer);
status.setText(errorMessage);
cancel.setText(R.string.DeviceTransfer__cancel);
cancel.setOnClickListener(v -> navigateAwayFromTransfer());
onBackPressed.isActiveTransfer = false;
}
protected class OnBackPressed extends OnBackPressedCallback {
private boolean isActiveTransfer = true;
public OnBackPressed() {
super(true);
}
@Override
public void handleOnBackPressed() {
if (isActiveTransfer) {
cancelActiveTransfer();
} else {
navigateAwayFromTransfer();
}
}
}
}

View file

@ -1,22 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.devicetransfer.moreoptions
/**
* Allows component opening sheet to specify mode
*/
enum class MoreTransferOrRestoreOptionsMode {
/**
* Only display the option to log in without transferring. Selection
* will be disabled.
*/
SKIP_ONLY,
/**
* Display transfer/restore local/skip as well as a next and cancel button
*/
SELECTION
}

View file

@ -1,339 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.devicetransfer.moreoptions
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType
import org.signal.core.ui.R as CoreUiR
/**
* Lists a set of options the user can choose from for restoring backup or skipping restoration
*/
class MoreTransferOrRestoreOptionsSheet : ComposeBottomSheetDialogFragment() {
private val args by navArgs<MoreTransferOrRestoreOptionsSheetArgs>()
@Composable
override fun SheetContent() {
var selectedOption by remember {
mutableStateOf<BackupRestorationType?>(null)
}
MoreOptionsSheetContent(
mode = args.mode,
selectedOption = selectedOption,
onOptionSelected = { selectedOption = it },
onCancelClick = { findNavController().popBackStack() },
onNextClick = {
this.onNextClicked(selectedOption ?: BackupRestorationType.NONE)
}
)
}
private fun onNextClicked(selectedOption: BackupRestorationType) {
// TODO [message-requests] -- Launch next screen based off user choice
}
}
@Preview
@Composable
private fun MoreOptionsSheetContentPreview() {
Previews.BottomSheetPreview {
MoreOptionsSheetContent(
mode = MoreTransferOrRestoreOptionsMode.SKIP_ONLY,
selectedOption = null,
onOptionSelected = {},
onCancelClick = {},
onNextClick = {}
)
}
}
@Preview
@Composable
private fun MoreOptionsSheetSelectableContentPreview() {
Previews.BottomSheetPreview {
MoreOptionsSheetContent(
mode = MoreTransferOrRestoreOptionsMode.SELECTION,
selectedOption = null,
onOptionSelected = {},
onCancelClick = {},
onNextClick = {}
)
}
}
@Composable
private fun MoreOptionsSheetContent(
mode: MoreTransferOrRestoreOptionsMode,
selectedOption: BackupRestorationType?,
onOptionSelected: (BackupRestorationType) -> Unit,
onCancelClick: () -> Unit,
onNextClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
) {
BottomSheets.Handle()
Spacer(modifier = Modifier.size(42.dp))
if (mode == MoreTransferOrRestoreOptionsMode.SELECTION) {
TransferFromAndroidDeviceOption(
selectedOption = selectedOption,
onOptionSelected = onOptionSelected
)
Spacer(modifier = Modifier.size(16.dp))
RestoreLocalBackupOption(
selectedOption = selectedOption,
onOptionSelected = onOptionSelected
)
Spacer(modifier = Modifier.size(16.dp))
}
LogInWithoutTransferringOption(
selectedOption = selectedOption,
onOptionSelected = when (mode) {
MoreTransferOrRestoreOptionsMode.SKIP_ONLY -> { _ -> onNextClick() }
MoreTransferOrRestoreOptionsMode.SELECTION -> onOptionSelected
}
)
if (mode == MoreTransferOrRestoreOptionsMode.SELECTION) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 30.dp, bottom = 24.dp)
) {
TextButton(
onClick = onCancelClick
) {
Text(text = stringResource(id = android.R.string.cancel))
}
Spacer(modifier = Modifier.weight(1f))
Buttons.LargeTonal(
enabled = selectedOption != null,
onClick = onNextClick
) {
Text(text = stringResource(id = R.string.RegistrationActivity_next))
}
}
} else {
Spacer(modifier = Modifier.size(45.dp))
}
}
}
@Preview
@Composable
private fun LogInWithoutTransferringOptionPreview() {
Previews.BottomSheetPreview {
LogInWithoutTransferringOption(
selectedOption = null,
onOptionSelected = {}
)
}
}
@Composable
private fun LogInWithoutTransferringOption(
selectedOption: BackupRestorationType?,
onOptionSelected: (BackupRestorationType) -> Unit
) {
Option(
icon = {
Box(
modifier = Modifier.padding(horizontal = 18.dp)
) {
Icon(
painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [backups] Finalized asset.
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp)
)
}
},
isSelected = selectedOption == BackupRestorationType.NONE,
title = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__log_in_without_transferring),
subtitle = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__continue_without_transferring),
onClick = { onOptionSelected(BackupRestorationType.NONE) }
)
}
@Preview
@Composable
private fun TransferFromAndroidDeviceOptionPreview() {
Previews.BottomSheetPreview {
TransferFromAndroidDeviceOption(
selectedOption = null,
onOptionSelected = {}
)
}
}
@Composable
private fun TransferFromAndroidDeviceOption(
selectedOption: BackupRestorationType?,
onOptionSelected: (BackupRestorationType) -> Unit
) {
Option(
icon = {
Box(
modifier = Modifier.padding(horizontal = 18.dp)
) {
Icon(
painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [backups] Finalized asset.
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp)
)
}
},
isSelected = selectedOption == BackupRestorationType.DEVICE_TRANSFER,
title = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__transfer_from_android_device),
subtitle = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__transfer_your_account_and_messages),
onClick = { onOptionSelected(BackupRestorationType.DEVICE_TRANSFER) }
)
}
@Preview
@Composable
private fun RestoreLocalBackupOptionPreview() {
Previews.BottomSheetPreview {
RestoreLocalBackupOption(
selectedOption = null,
onOptionSelected = {}
)
}
}
@Composable
private fun RestoreLocalBackupOption(
selectedOption: BackupRestorationType?,
onOptionSelected: (BackupRestorationType) -> Unit
) {
Option(
icon = {
Box(
modifier = Modifier.padding(horizontal = 18.dp)
) {
Icon(
painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [backups] Finalized asset.
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp)
)
}
},
isSelected = selectedOption == BackupRestorationType.LOCAL_BACKUP,
title = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__restore_local_backup),
subtitle = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__restore_your_messages),
onClick = { onOptionSelected(BackupRestorationType.LOCAL_BACKUP) }
)
}
@Preview
@Composable
private fun OptionPreview() {
Previews.BottomSheetPreview {
Option(
icon = {
Box(
modifier = Modifier.padding(horizontal = 18.dp)
) {
Icon(
painter = painterResource(id = R.drawable.symbol_backup_light),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp)
)
}
},
isSelected = false,
title = "Option Preview Title",
subtitle = "Option Preview Subtitle",
onClick = {}
)
}
}
@Composable
private fun Option(
icon: @Composable () -> Unit,
isSelected: Boolean,
title: String,
subtitle: String,
onClick: () -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.background(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(12.dp)
)
.border(
width = if (isSelected) 2.dp else 0.dp,
color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent
)
.clip(RoundedCornerShape(12.dp))
.clickable { onClick() }
.padding(vertical = 21.dp)
) {
icon()
Column {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View file

@ -73,6 +73,8 @@ final class NewDeviceServerTask implements ServerTask {
long end = System.currentTimeMillis();
Log.i(TAG, "Receive took: " + (end - start));
EventBus.getDefault().post(new Status(0, Status.State.RESTORE_COMPLETE));
}
@Subscribe(threadMode = ThreadMode.POSTING)
@ -80,7 +82,7 @@ final class NewDeviceServerTask implements ServerTask {
if (event.getType() == BackupEvent.Type.PROGRESS) {
EventBus.getDefault().post(new Status(event.getCount(), Status.State.IN_PROGRESS));
} else if (event.getType() == BackupEvent.Type.FINISHED) {
EventBus.getDefault().post(new Status(event.getCount(), Status.State.SUCCESS));
EventBus.getDefault().post(new Status(event.getCount(), Status.State.TRANSFER_COMPLETE));
}
}
@ -103,7 +105,8 @@ final class NewDeviceServerTask implements ServerTask {
public enum State {
IN_PROGRESS,
SUCCESS,
TRANSFER_COMPLETE,
RESTORE_COMPLETE,
FAILURE_VERSION_DOWNGRADE,
FAILURE_FOREIGN_KEY,
FAILURE_UNKNOWN

View file

@ -6,11 +6,10 @@ import android.view.View;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.navigation.fragment.NavHostFragment;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
import org.thoughtcrime.securesms.restore.RestoreActivity;
/**
* Shown after the new device successfully completes receiving a backup from the old device.
@ -23,8 +22,7 @@ public final class NewDeviceTransferCompleteFragment extends LoggingFragment {
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
view.findViewById(R.id.new_device_transfer_complete_fragment_continue_registration)
.setOnClickListener(v -> SafeNavigation.safeNavigate(NavHostFragment.findNavController(this),
R.id.action_newDeviceTransferComplete_to_enterPhoneNumberFragment));
.setOnClickListener(v -> ((RestoreActivity) requireActivity()).onBackupCompletedSuccessfully());
}
@Override

View file

@ -1,80 +0,0 @@
package org.thoughtcrime.securesms.devicetransfer.newdevice;
import android.os.Bundle;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.navigation.fragment.NavHostFragment;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.devicetransfer.DeviceToDeviceTransferService;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.devicetransfer.DeviceTransferFragment;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
/**
* Shows transfer progress on the new device. Most logic is in {@link DeviceTransferFragment}
* and it delegates to this class for strings, navigation, and updating progress.
*/
public final class NewDeviceTransferFragment extends DeviceTransferFragment {
private final ServerTaskListener serverTaskListener = new ServerTaskListener();
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
EventBus.getDefault().register(serverTaskListener);
}
@Override
public void onDestroyView() {
EventBus.getDefault().unregister(serverTaskListener);
super.onDestroyView();
}
@Override
protected void navigateToRestartTransfer() {
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), NewDeviceTransferFragmentDirections.actionNewDeviceTransferToNewDeviceTransferInstructions());
}
@Override
protected void navigateAwayFromTransfer() {
EventBus.getDefault().unregister(serverTaskListener);
requireActivity().finish();
}
@Override
protected void navigateToTransferComplete() {
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), NewDeviceTransferFragmentDirections.actionNewDeviceTransferToNewDeviceTransferComplete());
}
private class ServerTaskListener {
@Subscribe(threadMode = ThreadMode.MAIN)
public void onEventMainThread(@NonNull NewDeviceServerTask.Status event) {
status.setText(getString(R.string.DeviceTransfer__d_messages_so_far, event.getMessageCount()));
switch (event.getState()) {
case IN_PROGRESS:
break;
case SUCCESS:
transferFinished = true;
DeviceToDeviceTransferService.stop(requireContext());
SignalStore.registration().markRestoreCompleted();
navigateToTransferComplete();
break;
case FAILURE_VERSION_DOWNGRADE:
abort(R.string.NewDeviceTransfer__cannot_transfer_from_a_newer_version_of_signal);
break;
case FAILURE_FOREIGN_KEY:
abort(R.string.NewDeviceTransfer__failure_foreign_key);
break;
case FAILURE_UNKNOWN:
abort();
break;
}
}
}
}

View file

@ -0,0 +1,79 @@
package org.thoughtcrime.securesms.devicetransfer.newdevice
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.devicetransfer.DeviceToDeviceTransferService
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.restore.RestoreActivity
import org.thoughtcrime.securesms.restore.devicetransfer.DeviceTransferFragment
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Shows transfer progress on the new device. Most logic is in [DeviceTransferFragment]
* and it delegates to this class for strings, navigation, and updating progress.
*/
class NewDeviceTransferFragment : DeviceTransferFragment() {
private val viewModel: NewDeviceTransferViewModel by viewModels()
private val serverTaskListener = ServerTaskListener()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
EventBus.getDefault().register(serverTaskListener)
}
override fun onDestroyView() {
EventBus.getDefault().unregister(serverTaskListener)
super.onDestroyView()
}
override fun navigateToRestartTransfer() {
findNavController().safeNavigate(NewDeviceTransferFragmentDirections.actionNewDeviceTransferToNewDeviceTransferInstructions())
}
override fun navigateAwayFromTransfer() {
EventBus.getDefault().unregister(serverTaskListener)
requireActivity().finish()
}
override fun navigateToTransferComplete() {
if (SignalStore.account.isRegistered) {
(requireActivity() as RestoreActivity).onBackupCompletedSuccessfully()
} else {
findNavController().safeNavigate(NewDeviceTransferFragmentDirections.actionNewDeviceTransferToNewDeviceTransferComplete())
}
}
private fun onRestoreComplete() {
ignoreTransferStatusEvents()
DeviceToDeviceTransferService.stop(requireContext())
viewModel.onRestoreComplete(requireContext()) {
transferFinished = true
navigateToTransferComplete()
}
}
private inner class ServerTaskListener {
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEventMainThread(event: NewDeviceServerTask.Status) {
status.text = getString(R.string.DeviceTransfer__d_messages_so_far, event.messageCount)
when (event.state) {
NewDeviceServerTask.Status.State.IN_PROGRESS,
NewDeviceServerTask.Status.State.TRANSFER_COMPLETE -> Unit
NewDeviceServerTask.Status.State.RESTORE_COMPLETE -> onRestoreComplete()
NewDeviceServerTask.Status.State.FAILURE_VERSION_DOWNGRADE -> abort(R.string.NewDeviceTransfer__cannot_transfer_from_a_newer_version_of_signal)
NewDeviceServerTask.Status.State.FAILURE_FOREIGN_KEY -> abort(R.string.NewDeviceTransfer__failure_foreign_key)
NewDeviceServerTask.Status.State.FAILURE_UNKNOWN -> abort()
}
}
}
}

View file

@ -27,7 +27,7 @@ public final class NewDeviceTransferSetupFragment extends DeviceTransferSetupFra
@Override
protected void navigateAwayFromTransfer() {
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), R.id.action_deviceTransferSetup_to_transferOrRestore);
requireActivity().onNavigateUp();
}
@Override
@ -78,7 +78,7 @@ public final class NewDeviceTransferSetupFragment extends DeviceTransferSetupFra
@Override
protected void navigateWhenWifiDirectUnavailable() {
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), R.id.action_deviceTransferSetup_to_transferOrRestore);
requireActivity().onNavigateUp();
}
@Override

View file

@ -0,0 +1,34 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.devicetransfer.newdevice
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
class NewDeviceTransferViewModel : ViewModel() {
fun onRestoreComplete(context: Context, onComplete: () -> Unit) {
viewModelScope.launch {
SignalStore.registration.localRegistrationMetadata?.let { metadata ->
RegistrationRepository.registerAccountLocally(context, metadata)
SignalStore.registration.clearLocalRegistrationMetadata()
RegistrationUtil.maybeMarkRegistrationComplete()
}
SignalStore.registration.markRestoreCompleted()
withContext(Dispatchers.Main) {
onComplete()
}
}
}
}

View file

@ -1,71 +0,0 @@
package org.thoughtcrime.securesms.devicetransfer.newdevice;
import android.os.Bundle;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.Navigation;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.databinding.FragmentTransferRestoreBinding;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
/**
* Simple jumping off menu to starts a device-to-device transfer or restore a backup.
*/
public final class TransferOrRestoreFragment extends LoggingFragment {
private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable();
private FragmentTransferRestoreBinding binding;
public TransferOrRestoreFragment() {
super(R.layout.fragment_transfer_restore);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
binding = FragmentTransferRestoreBinding.bind(view);
TransferOrRestoreViewModel viewModel = new ViewModelProvider(this).get(TransferOrRestoreViewModel.class);
binding.transferOrRestoreFragmentTransfer.setOnClickListener(v -> viewModel.onTransferFromAndroidDeviceSelected());
binding.transferOrRestoreFragmentRestore.setOnClickListener(v -> viewModel.onRestoreFromLocalBackupSelected());
binding.transferOrRestoreFragmentRestoreRemote.setOnClickListener(v -> viewModel.onRestoreFromRemoteBackupSelected());
binding.transferOrRestoreFragmentNext.setOnClickListener(v -> launchSelection(viewModel.getStateSnapshot()));
binding.transferOrRestoreFragmentMoreOptions.setOnClickListener(v -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_transferOrRestore_to_moreOptions));
int visibility = RemoteConfig.messageBackups() ? View.VISIBLE : View.GONE;
binding.transferOrRestoreFragmentRestoreRemoteCard.setVisibility(visibility);
binding.transferOrRestoreFragmentMoreOptions.setVisibility(visibility);
String description = getString(R.string.TransferOrRestoreFragment__transfer_your_account_and_messages_from_your_old_android_device);
String toBold = getString(R.string.TransferOrRestoreFragment__you_need_access_to_your_old_device);
binding.transferOrRestoreFragmentTransferDescription.setText(SpanUtil.boldSubstring(description, toBold));
lifecycleDisposable.bindTo(getViewLifecycleOwner());
lifecycleDisposable.add(viewModel.getState().subscribe(this::updateSelection));
}
private void updateSelection(BackupRestorationType restorationType) {
binding.transferOrRestoreFragmentTransferCard.setSelected(restorationType == BackupRestorationType.DEVICE_TRANSFER);
binding.transferOrRestoreFragmentRestoreCard.setSelected(restorationType == BackupRestorationType.LOCAL_BACKUP);
binding.transferOrRestoreFragmentRestoreRemoteCard.setSelected(restorationType == BackupRestorationType.REMOTE_BACKUP);
}
private void launchSelection(BackupRestorationType restorationType) {
switch (restorationType) {
case DEVICE_TRANSFER -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_new_device_transfer_instructions);
case LOCAL_BACKUP -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_transfer_or_restore_to_local_restore);
case REMOTE_BACKUP -> {}
default -> throw new IllegalArgumentException();
}
}
}

View file

@ -1,34 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.devicetransfer.newdevice
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.processors.BehaviorProcessor
/**
* Maintains state of the TransferOrRestoreFragment
*/
class TransferOrRestoreViewModel : ViewModel() {
private val internalState = BehaviorProcessor.createDefault(BackupRestorationType.DEVICE_TRANSFER)
val state: Flowable<BackupRestorationType> = internalState.distinctUntilChanged().observeOn(AndroidSchedulers.mainThread())
val stateSnapshot: BackupRestorationType get() = internalState.value!!
fun onTransferFromAndroidDeviceSelected() {
internalState.onNext(BackupRestorationType.DEVICE_TRANSFER)
}
fun onRestoreFromLocalBackupSelected() {
internalState.onNext(BackupRestorationType.LOCAL_BACKUP)
}
fun onRestoreFromRemoteBackupSelected() {
internalState.onNext(BackupRestorationType.REMOTE_BACKUP)
}
}

View file

@ -13,7 +13,7 @@ import org.greenrobot.eventbus.ThreadMode;
import org.signal.devicetransfer.DeviceToDeviceTransferService;
import org.signal.devicetransfer.TransferStatus;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.devicetransfer.DeviceTransferFragment;
import org.thoughtcrime.securesms.restore.devicetransfer.DeviceTransferFragment;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
import java.text.NumberFormat;
@ -66,16 +66,16 @@ public final class OldDeviceTransferFragment extends DeviceTransferFragment {
@Subscribe(threadMode = ThreadMode.MAIN)
public void onEventMainThread(@NonNull OldDeviceClientTask.Status event) {
if (event.isDone()) {
transferFinished = true;
setTransferFinished(true);
ignoreTransferStatusEvents();
EventBus.getDefault().removeStickyEvent(TransferStatus.class);
DeviceToDeviceTransferService.stop(requireContext());
SafeNavigation.safeNavigate(NavHostFragment.findNavController(OldDeviceTransferFragment.this), R.id.action_oldDeviceTransfer_to_oldDeviceTransferComplete);
} else {
if (event.getEstimatedMessageCount() == 0) {
status.setText(getString(R.string.DeviceTransfer__d_messages_so_far, event.getMessageCount()));
getStatus().setText(getString(R.string.DeviceTransfer__d_messages_so_far, event.getMessageCount()));
} else {
status.setText(getString(R.string.DeviceTransfer__s_of_messages_so_far, formatter.format(event.getCompletionPercentage())));
getStatus().setText(getString(R.string.DeviceTransfer__s_of_messages_so_far, formatter.format(event.getCompletionPercentage())));
}
}
}

View file

@ -25,7 +25,8 @@ object SignalSymbols {
enum class Glyph(val unicode: Char) {
CHECKMARK('\u2713'),
CHEVRON_RIGHT('\uE025'),
PERSON_CIRCLE('\uE05E')
PERSON_CIRCLE('\uE05E'),
LOCK('\uE041')
}
enum class Weight {

View file

@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.jobs
import org.greenrobot.eventbus.EventBus
import org.signal.core.util.bytes
import org.signal.core.util.logging.Log
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.R
@ -75,7 +76,7 @@ class BackupRestoreJob private constructor(parameters: Parameters) : BaseJob(par
progress = progress.toFloat() / total.toFloat(),
indeterminate = false
)
EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_DOWNLOAD, progress, total))
EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_DOWNLOAD, progress.bytes, total.bytes))
}
override fun shouldCancel() = isCanceled

View file

@ -103,8 +103,8 @@ public class RefreshAttributesJob extends BaseJob {
String deviceName = SignalStore.account().getDeviceName();
byte[] encryptedDeviceName = (deviceName == null) ? null : DeviceNameCipher.encryptDeviceName(deviceName.getBytes(StandardCharsets.UTF_8), SignalStore.account().getAciIdentityKey());
AccountAttributes.Capabilities capabilities = AppCapabilities.getCapabilities(svrValues.hasPin() && !svrValues.hasOptedOut());
Log.i(TAG, "Calling setAccountAttributes() reglockV2? " + !TextUtils.isEmpty(registrationLockV2) + ", pin? " + svrValues.hasPin() +
AccountAttributes.Capabilities capabilities = AppCapabilities.getCapabilities(svrValues.hasOptedInWithAccess() && !svrValues.hasOptedOut());
Log.i(TAG, "Calling setAccountAttributes() reglockV2? " + !TextUtils.isEmpty(registrationLockV2) + ", pin? " + svrValues.hasPin() + ", access? " + svrValues.hasOptedInWithAccess() +
"\n Recovery password? " + !TextUtils.isEmpty(recoveryPassword) +
"\n Phone number discoverable : " + phoneNumberDiscoverable +
"\n Device Name : " + (encryptedDeviceName != null) +

View file

@ -105,7 +105,7 @@ public class RefreshOwnProfileJob extends BaseJob {
return;
}
if (SignalStore.svr().hasPin() && !SignalStore.svr().hasOptedOut() && SignalStore.storageService().getLastSyncTime() == 0) {
if (SignalStore.svr().hasOptedInWithAccess() && !SignalStore.svr().hasOptedOut() && SignalStore.storageService().getLastSyncTime() == 0) {
Log.i(TAG, "Registered with PIN but haven't completed storage sync yet.");
return;
}

View file

@ -24,7 +24,7 @@ class RefreshSvrCredentialsJob private constructor(parameters: Parameters) : Bas
@JvmStatic
fun enqueueIfNecessary() {
if (SignalStore.svr.hasPin() && SignalStore.account.isRegistered) {
if (SignalStore.svr.hasOptedInWithAccess() && SignalStore.account.isRegistered) {
val lastTimestamp = SignalStore.svr.lastRefreshAuthTimestamp
if (lastTimestamp + FREQUENCY.inWholeMilliseconds < System.currentTimeMillis() || lastTimestamp > System.currentTimeMillis()) {
AppDependencies.jobManager.add(RefreshSvrCredentialsJob())

View file

@ -139,7 +139,7 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param
@Throws(IOException::class, RetryLaterException::class, UntrustedIdentityException::class)
override fun onRun() {
if (!SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut()) {
if (!SignalStore.svr.hasOptedInWithAccess() && !SignalStore.svr.hasOptedOut()) {
Log.i(TAG, "Doesn't have a PIN. Skipping.")
return
}

View file

@ -154,7 +154,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
}
/**
* When uploading a backup, we store the progress state here so that I can remain across app restarts.
* When uploading a backup, we store the progress state here so that it can remain across app restarts.
*/
var archiveUploadState: ArchiveUploadProgressState? by protoValue(KEY_ARCHIVE_UPLOAD_STATE, ArchiveUploadProgressState.ADAPTER)

View file

@ -243,7 +243,8 @@ class PaymentsValues internal constructor(store: KeyValueStore) : SignalStoreVal
fun showUpdatePinInfoCard(): Boolean {
return if (userHasLargeBalance() &&
SignalStore.svr.hasPin() &&
!SignalStore.svr.hasOptedOut() && SignalStore.pin.keyboardType == PinKeyboardType.NUMERIC
!SignalStore.svr.hasOptedOut() &&
SignalStore.pin.keyboardType == PinKeyboardType.NUMERIC
) {
store.getBoolean(SHOW_CASHING_OUT_INFO_CARD, true)
} else {

View file

@ -96,7 +96,7 @@ public final class RegistrationValues extends SignalStoreValues {
putBoolean(SKIPPED_TRANSFER_OR_RESTORE, true);
}
public void clearSkippedTransferOrRestore() {
public void debugClearSkippedTransferOrRestore() {
putBoolean(SKIPPED_TRANSFER_OR_RESTORE, false);
}

View file

@ -25,6 +25,7 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
private const val SVR2_AUTH_TOKENS = "kbs.kbs_auth_tokens"
private const val SVR_LAST_AUTH_REFRESH_TIMESTAMP = "kbs.kbs_auth_tokens.last_refresh_timestamp"
private const val SVR3_AUTH_TOKENS = "kbs.svr3_auth_tokens"
private const val RESTORED_VIA_ACCOUNT_ENTROPY_KEY = "kbs.restore_via_account_entropy_pool"
}
public override fun onFirstEverAppLaunch() = Unit
@ -52,14 +53,22 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
}
@Synchronized
fun setMasterKey(masterKey: MasterKey, pin: String) {
store.beginWrite()
.putBlob(MASTER_KEY, masterKey.serialize())
.putString(LOCK_LOCAL_PIN_HASH, localPinHash(pin))
.putString(PIN, pin)
.putLong(LAST_CREATE_FAILED_TIMESTAMP, -1)
.putBoolean(OPTED_OUT, false)
.commit()
fun setMasterKey(masterKey: MasterKey, pin: String?) {
store.beginWrite().apply {
putBlob(MASTER_KEY, masterKey.serialize())
putLong(LAST_CREATE_FAILED_TIMESTAMP, -1)
putBoolean(OPTED_OUT, false)
if (pin != null) {
putString(LOCK_LOCAL_PIN_HASH, localPinHash(pin))
putString(PIN, pin)
remove(RESTORED_VIA_ACCOUNT_ENTROPY_KEY)
} else {
putBoolean(RESTORED_VIA_ACCOUNT_ENTROPY_KEY, true)
remove(LOCK_LOCAL_PIN_HASH)
remove(PIN)
}
}.commit()
}
@Synchronized
@ -85,9 +94,9 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
return getLong(LAST_CREATE_FAILED_TIMESTAMP, -1) > 0
}
/** Returns the Master Key, lazily creating one if needed. */
@get:Synchronized
val masterKey: MasterKey
/** Returns the Master Key, lazily creating one if needed. */
get() {
val blob = store.getBlob(MASTER_KEY, null)
if (blob != null) {
@ -123,7 +132,7 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
val recoveryPassword: String?
get() {
val masterKey = rawMasterKey
return if (masterKey != null && hasPin()) {
return if (masterKey != null && hasOptedInWithAccess()) {
masterKey.deriveRegistrationRecoveryPassword()
} else {
null
@ -136,11 +145,19 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
@get:Synchronized
val localPinHash: String? by stringValue(LOCK_LOCAL_PIN_HASH, null)
@Synchronized
fun hasOptedInWithAccess(): Boolean {
return hasPin() || restoredViaAccountEntropyPool
}
@Synchronized
fun hasPin(): Boolean {
return localPinHash != null
}
@get:Synchronized
val restoredViaAccountEntropyPool by booleanValue(RESTORED_VIA_ACCOUNT_ENTROPY_KEY, false)
@get:Synchronized
@set:Synchronized
var isPinForgottenOrSkipped: Boolean by booleanValue(PIN_FORGOTTEN_OR_SKIPPED, false)
@ -229,6 +246,7 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
.putBlob(MASTER_KEY, MasterKey.createNew(SecureRandom()).serialize())
.remove(LOCK_LOCAL_PIN_HASH)
.remove(PIN)
.remove(RESTORED_VIA_ACCOUNT_ENTROPY_KEY)
.putLong(LAST_CREATE_FAILED_TIMESTAMP, -1)
.commit()
}

View file

@ -101,7 +101,7 @@ public abstract class BaseSvrPinFragment<ViewModel extends BaseSvrPinViewModel>
@Override
public void onPrepareOptionsMenu(@NonNull Menu menu) {
if (SignalStore.svr().isRegistrationLockEnabled() ||
SignalStore.svr().hasPin() ||
SignalStore.svr().hasOptedInWithAccess() ||
SignalStore.svr().hasOptedOut())
{
menu.clear();

View file

@ -115,7 +115,7 @@ public final class SvrSplashFragment extends Fragment {
private void onCreatePin() {
SvrSplashFragmentDirections.ActionCreateKbsPin action = SvrSplashFragmentDirections.actionCreateKbsPin();
action.setIsPinChange(SignalStore.svr().hasPin());
action.setIsPinChange(SignalStore.svr().hasOptedInWithAccess());
SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), action);
}

View file

@ -19,6 +19,7 @@ public class LogSectionPin implements LogSection {
.append("Next Reminder Interval: ").append(SignalStore.pin().getCurrentInterval()).append("\n")
.append("Reglock: ").append(SignalStore.svr().isRegistrationLockEnabled()).append("\n")
.append("Signal PIN: ").append(SignalStore.svr().hasPin()).append("\n")
.append("Restored via AEP: ").append(SignalStore.svr().getRestoredViaAccountEntropyPool()).append("\n")
.append("Opted Out: ").append(SignalStore.svr().hasOptedOut()).append("\n")
.append("Last Creation Failed: ").append(SignalStore.svr().lastPinCreateFailed()).append("\n")
.append("Needs Account Restore: ").append(SignalStore.storageService().needsAccountRestore()).append("\n")

View file

@ -3,9 +3,10 @@ package org.thoughtcrime.securesms.mediasend.v2.capture
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.recipients.Recipient
sealed class MediaCaptureEvent {
data class MediaCaptureRendered(val media: Media) : MediaCaptureEvent()
data class UsernameScannedFromQrCode(val recipient: Recipient, val username: String) : MediaCaptureEvent()
object DeviceLinkScannedFromQrCode : MediaCaptureEvent()
object MediaCaptureRenderFailed : MediaCaptureEvent()
sealed interface MediaCaptureEvent {
data class MediaCaptureRendered(val media: Media) : MediaCaptureEvent
data class UsernameScannedFromQrCode(val recipient: Recipient, val username: String) : MediaCaptureEvent
data object DeviceLinkScannedFromQrCode : MediaCaptureEvent
data object MediaCaptureRenderFailed : MediaCaptureEvent
data class ReregistrationScannedFromQrCode(val data: String) : MediaCaptureEvent
}

View file

@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.registrationv3.olddevice.TransferAccountActivity
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@ -67,6 +68,7 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
Log.w(TAG, "Failed to render captured media.")
Toast.makeText(requireContext(), R.string.MediaSendActivity_camera_unavailable, Toast.LENGTH_SHORT).show()
}
is MediaCaptureEvent.MediaCaptureRendered -> {
if (isFirst()) {
sharedViewModel.addCameraFirstCapture(event.media)
@ -76,6 +78,7 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
navigator.goToReview(findNavController())
}
is MediaCaptureEvent.UsernameScannedFromQrCode -> {
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.MediaCaptureFragment_username_dialog_title, event.username))
@ -87,6 +90,7 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
.setNegativeButton(android.R.string.cancel, null)
.show()
}
is MediaCaptureEvent.DeviceLinkScannedFromQrCode -> {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.MediaCaptureFragment_device_link_dialog_title)
@ -98,6 +102,11 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
.setNegativeButton(android.R.string.cancel, null)
.show()
}
is MediaCaptureEvent.ReregistrationScannedFromQrCode -> {
startActivity(TransferAccountActivity.intent(requireContext(), event.data))
requireActivity().finish()
}
}
}

View file

@ -11,10 +11,11 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.QrScanResult
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository
import org.thoughtcrime.securesms.util.rx.RxStore
import java.io.FileDescriptor
import java.util.Optional
@ -71,6 +72,15 @@ class MediaCaptureViewModel(private val repository: MediaCaptureRepository) : Vi
.subscribe { data ->
internalEvents.onNext(MediaCaptureEvent.DeviceLinkScannedFromQrCode)
}
if (SignalStore.account.isRegistered) {
disposables += qrData
.throttleFirst(5, TimeUnit.SECONDS)
.filter { it.startsWith("sgnl://rereg") && QuickRegistrationRepository.isValidReRegistrationQr(it) }
.subscribe { data ->
internalEvents.onNext(MediaCaptureEvent.ReregistrationScannedFromQrCode(data))
}
}
}
override fun onCleared() {

View file

@ -45,7 +45,7 @@ class PinsForAllSchedule implements MegaphoneSchedule {
return false;
}
if (SignalStore.svr().hasPin()) {
if (SignalStore.svr().hasOptedInWithAccess()) {
return false;
}
@ -62,6 +62,6 @@ class PinsForAllSchedule implements MegaphoneSchedule {
private static boolean pinCreationFailedDuringRegistration() {
return SignalStore.registration().pinWasRequiredAtRegistration() &&
!SignalStore.svr().hasPin();
!SignalStore.svr().hasOptedInWithAccess();
}
}

View file

@ -37,7 +37,7 @@ public final class PinOptOutMigration extends MigrationJob {
@Override
void performMigration() {
if (SignalStore.svr().hasOptedOut() && SignalStore.svr().hasPin()) {
if (SignalStore.svr().hasOptedOut() && SignalStore.svr().hasOptedInWithAccess()) {
Log.w(TAG, "Discovered a legacy opt-out user! Resetting the state.");
SignalStore.svr().optOut();

View file

@ -240,7 +240,7 @@ public class PinRestoreEntryFragment extends LoggingFragment {
Activity activity = requireActivity();
if (RemoteConfig.messageBackups() && !SignalStore.registration().hasCompletedRestore()) {
final Intent transferOrRestore = RestoreActivity.getIntentForTransferOrRestore(activity);
final Intent transferOrRestore = RestoreActivity.getRestoreIntent(activity);
transferOrRestore.putExtra(PassphraseRequiredActivity.NEXT_INTENT_EXTRA, MainActivity.clearTop(requireContext()));
startActivity(transferOrRestore);
} else if (Recipient.self().getProfileName().isEmpty() || !AvatarHelper.hasAvatar(activity, Recipient.self().getId())) {

View file

@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.pin
import android.app.backup.BackupManager
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BuildConfig
@ -163,10 +164,13 @@ object SvrRepository {
Log.i(TAG, "[restoreMasterKeyPostRegistration] Successfully restored master key. $implementation", true)
stopwatch.split("restore")
SignalStore.registration.localRegistrationMetadata?.let { metadata ->
SignalStore.registration.localRegistrationMetadata = metadata.copy(masterKey = response.masterKey.serialize().toByteString(), pin = userPin)
}
SignalStore.svr.setMasterKey(response.masterKey, userPin)
SignalStore.svr.isRegistrationLockEnabled = false
SignalStore.pin.resetPinReminders()
SignalStore.svr.isPinForgottenOrSkipped = false
SignalStore.pin.keyboardType = pinKeyboardType
SignalStore.storageService.setNeedsAccountRestore(false)
@ -264,7 +268,6 @@ object SvrRepository {
Log.i(TAG, "[setPin] Success!", true)
SignalStore.svr.setMasterKey(masterKey, userPin)
SignalStore.svr.isPinForgottenOrSkipped = false
responses
.filterIsInstance<BackupResponse.Success>()
.forEach {
@ -321,6 +324,9 @@ object SvrRepository {
SignalStore.pin.resetPinReminders()
AppDependencies.jobManager.add(ResetSvrGuessCountJob())
} else if (masterKey != null) {
Log.i(TAG, "[onRegistrationComplete] ReRegistered with key without pin")
SignalStore.svr.setMasterKey(masterKey, null)
} else if (hasPinToRestore) {
Log.i(TAG, "[onRegistrationComplete] Has a PIN to restore.", true)
SignalStore.svr.clearRegistrationLockAndPin()
@ -342,7 +348,6 @@ object SvrRepository {
operationLock.withLock {
SignalStore.svr.clearRegistrationLockAndPin()
SignalStore.storageService.setNeedsAccountRestore(false)
SignalStore.svr.isPinForgottenOrSkipped = true
}
}
@ -364,7 +369,7 @@ object SvrRepository {
@Throws(IOException::class)
fun enableRegistrationLockForUserWithPin() {
operationLock.withLock {
check(SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to set a registration lock!" }
check(SignalStore.svr.hasOptedInWithAccess() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to set a registration lock!" }
Log.i(TAG, "[enableRegistrationLockForUserWithPin] Enabling registration lock.", true)
AppDependencies.signalServiceAccountManager.enableRegistrationLock(SignalStore.svr.masterKey)
@ -378,7 +383,7 @@ object SvrRepository {
@Throws(IOException::class)
fun disableRegistrationLockForUserWithPin() {
operationLock.withLock {
check(SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to disable registration lock!" }
check(SignalStore.svr.hasOptedInWithAccess() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to disable registration lock!" }
Log.i(TAG, "[disableRegistrationLockForUserWithPin] Disabling registration lock.", true)
AppDependencies.signalServiceAccountManager.disableRegistrationLock()
@ -408,7 +413,7 @@ object SvrRepository {
false
}
if (newToken && SignalStore.svr.hasPin()) {
if (newToken && SignalStore.svr.hasOptedInWithAccess()) {
BackupManager(AppDependencies.application).dataChanged()
}
} catch (e: Throwable) {
@ -469,7 +474,7 @@ object SvrRepository {
private val hasNoRegistrationLock: Boolean
get() {
return !SignalStore.svr.isRegistrationLockEnabled &&
!SignalStore.svr.hasPin() &&
!SignalStore.svr.hasOptedInWithAccess() &&
!SignalStore.svr.hasOptedOut()
}
}

View file

@ -0,0 +1,20 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.data
import org.whispersystems.signalservice.api.account.PreKeyCollection
import org.whispersystems.signalservice.api.kbs.MasterKey
data class AccountRegistrationResult(
val uuid: String,
val pni: String,
val storageCapable: Boolean,
val number: String,
val masterKey: MasterKey?,
val pin: String?,
val aciPreKeyCollection: PreKeyCollection,
val pniPreKeyCollection: PreKeyCollection
)

View file

@ -17,7 +17,7 @@ import org.whispersystems.signalservice.api.account.PreKeyCollection
* and combines them into a proto-backed class [LocalRegistrationMetadata] so they can be serialized & stored.
*/
object LocalRegistrationMetadataUtil {
fun createLocalRegistrationMetadata(localAciIdentityKeyPair: IdentityKeyPair, localPniIdentityKeyPair: IdentityKeyPair, registrationData: RegistrationData, remoteResult: RegistrationRepository.AccountRegistrationResult, reglockEnabled: Boolean): LocalRegistrationMetadata {
fun createLocalRegistrationMetadata(localAciIdentityKeyPair: IdentityKeyPair, localPniIdentityKeyPair: IdentityKeyPair, registrationData: RegistrationData, remoteResult: AccountRegistrationResult, reglockEnabled: Boolean): LocalRegistrationMetadata {
return LocalRegistrationMetadata.Builder().apply {
aciIdentityKeyPair = localAciIdentityKeyPair.serialize().toByteString()
aciSignedPreKey = remoteResult.aciPreKeyCollection.signedPreKey.serialize().toByteString()

View file

@ -622,15 +622,4 @@ object RegistrationRepository {
latch.countDown()
}
}
data class AccountRegistrationResult(
val uuid: String,
val pni: String,
val storageCapable: Boolean,
val number: String,
val masterKey: MasterKey?,
val pin: String?,
val aciPreKeyCollection: PreKeyCollection,
val pniPreKeyCollection: PreKeyCollection
)
}

View file

@ -6,7 +6,7 @@
package org.thoughtcrime.securesms.registration.data.network
import org.thoughtcrime.securesms.pin.SvrWrongPinException
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException
@ -23,7 +23,7 @@ import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
*/
sealed class RegisterAccountResult(cause: Throwable?) : RegistrationResult(cause) {
companion object {
fun from(networkResult: NetworkResult<RegistrationRepository.AccountRegistrationResult>): RegisterAccountResult {
fun from(networkResult: NetworkResult<AccountRegistrationResult>): RegisterAccountResult {
return when (networkResult) {
is NetworkResult.Success -> Success(networkResult.result)
is NetworkResult.ApplicationError -> UnknownError(networkResult.throwable)
@ -55,7 +55,7 @@ sealed class RegisterAccountResult(cause: Throwable?) : RegistrationResult(cause
}
}
}
class Success(val accountRegistrationResult: RegistrationRepository.AccountRegistrationResult) : RegisterAccountResult(null)
class Success(val accountRegistrationResult: AccountRegistrationResult) : RegisterAccountResult(null)
class IncorrectRecoveryPassword(cause: Throwable) : RegisterAccountResult(cause)
class AuthorizationFailed(cause: Throwable) : RegisterAccountResult(cause)
class MalformedRequest(cause: Throwable) : RegisterAccountResult(cause)

View file

@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.sms.SmsRetrieverReceiver
import org.thoughtcrime.securesms.registration.ui.restore.RemoteRestoreActivity
import org.thoughtcrime.securesms.registrationv3.ui.restore.RemoteRestoreActivity
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.RemoteConfig
@ -119,7 +119,7 @@ class RegistrationActivity : BaseActivity() {
@JvmStatic
fun newIntentForNewRegistration(context: Context, originalIntent: Intent): Intent {
return Intent(context, RegistrationActivity::class.java).apply {
return Intent(context, getRegistrationClass()).apply {
putExtra(RE_REGISTRATION_EXTRA, false)
setData(originalIntent.data)
}
@ -127,9 +127,13 @@ class RegistrationActivity : BaseActivity() {
@JvmStatic
fun newIntentForReRegistration(context: Context): Intent {
return Intent(context, RegistrationActivity::class.java).apply {
return Intent(context, getRegistrationClass()).apply {
putExtra(RE_REGISTRATION_EXTRA, true)
}
}
private fun getRegistrationClass(): Class<*> {
return if (RemoteConfig.restoreAfterRegistration) org.thoughtcrime.securesms.registrationv3.ui.RegistrationActivity::class.java else RegistrationActivity::class.java
}
}
}

View file

@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.pin.SvrWrongPinException
import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil
import org.thoughtcrime.securesms.registration.data.RegistrationData
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
@ -827,7 +828,7 @@ class RegistrationViewModel : ViewModel() {
handleRegistrationResult(context, registrationData, registrationResponse, false)
}
private suspend fun onSuccessfulRegistration(context: Context, registrationData: RegistrationData, remoteResult: RegistrationRepository.AccountRegistrationResult, reglockEnabled: Boolean) {
private suspend fun onSuccessfulRegistration(context: Context, registrationData: RegistrationData, remoteResult: AccountRegistrationResult, reglockEnabled: Boolean) {
Log.v(TAG, "onSuccessfulRegistration()")
val metadata = LocalRegistrationMetadataUtil.createLocalRegistrationMetadata(SignalStore.account.aciIdentityKey, SignalStore.account.pniIdentityKey, registrationData, remoteResult, reglockEnabled)
RegistrationRepository.registerAccountLocally(context, metadata)

View file

@ -104,7 +104,7 @@ class GrantPermissionsFragment : ComposeFragment() {
when (welcomeAction) {
WelcomeAction.CONTINUE -> findNavController().safeNavigate(GrantPermissionsFragmentDirections.actionEnterPhoneNumber())
WelcomeAction.RESTORE_BACKUP -> {
val restoreIntent = RestoreActivity.getIntentForTransferOrRestore(requireActivity())
val restoreIntent = RestoreActivity.getRestoreIntent(requireActivity())
launchRestoreActivity.launch(restoreIntent)
}
}

View file

@ -1,380 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeature
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeatureRow
import org.thoughtcrime.securesms.backup.v2.ui.subscription.RemoteRestoreViewModel
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.restore.transferorrestore.TransferOrRestoreMoreOptionsDialog
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.Util
import java.util.Locale
import org.signal.core.ui.R as CoreUiR
class RemoteRestoreActivity : BaseActivity() {
companion object {
fun getIntent(context: Context): Intent {
return Intent(context, RemoteRestoreActivity::class.java)
}
}
private val viewModel: RemoteRestoreViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val state by viewModel.state
SignalTheme {
Surface {
RestoreFromBackupContent(
features = getFeatureList(state.backupTier),
onRestoreBackupClick = {
viewModel.restore()
},
onCancelClick = {
finish()
},
onMoreOptionsClick = {
TransferOrRestoreMoreOptionsDialog.show(fragmentManager = supportFragmentManager, skipOnly = false)
},
state.backupTier,
state.backupTime,
state.backupTier != MessageBackupTier.PAID
)
if (state.importState == RemoteRestoreViewModel.ImportState.RESTORED) {
SideEffect {
SignalStore.registration.markRestoreCompleted()
RegistrationUtil.maybeMarkRegistrationComplete()
AppDependencies.jobManager.add(ProfileUploadJob())
startActivity(MainActivity.clearTop(this))
}
} else if (state.importState == RemoteRestoreViewModel.ImportState.IN_PROGRESS) {
ProgressDialog(state.restoreProgress)
}
}
}
}
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEvent(restoreEvent: RestoreV2Event) {
viewModel.updateRestoreProgress(restoreEvent)
}
@Composable
private fun getFeatureList(tier: MessageBackupTier?): ImmutableList<MessageBackupsTypeFeature> {
return when (tier) {
null -> persistentListOf()
MessageBackupTier.PAID -> {
persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_media)
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_recent_compact_bold_16,
label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_messages)
)
)
}
MessageBackupTier.FREE -> {
persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = stringResource(id = R.string.RemoteRestoreActivity__your_last_d_days_of_media, 30)
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_recent_compact_bold_16,
label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_messages)
)
)
}
}
}
/**
* A dialog that *just* shows a spinner. Useful for short actions where you need to
* let the user know that some action is completing.
*/
@Composable
fun ProgressDialog(restoreProgress: RestoreV2Event?) {
androidx.compose.material3.AlertDialog(
onDismissRequest = {},
confirmButton = {},
dismissButton = {},
text = {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.wrapContentSize()
) {
if (restoreProgress == null) {
CircularProgressIndicator(
modifier = Modifier
.padding(top = 55.dp, bottom = 16.dp)
.width(48.dp)
.height(48.dp)
)
} else {
CircularProgressIndicator(
progress = restoreProgress.getProgress(),
modifier = Modifier
.padding(top = 55.dp, bottom = 16.dp)
.width(48.dp)
.height(48.dp)
)
}
val progressText = when (restoreProgress?.type) {
RestoreV2Event.Type.PROGRESS_DOWNLOAD -> stringResource(id = R.string.RemoteRestoreActivity__downloading_backup)
RestoreV2Event.Type.PROGRESS_RESTORE -> stringResource(id = R.string.RemoteRestoreActivity__downloading_backup)
else -> stringResource(id = R.string.RemoteRestoreActivity__restoring)
}
Text(
text = progressText,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 12.dp)
)
if (restoreProgress != null) {
val progressBytes = Util.getPrettyFileSize(restoreProgress.count)
val totalBytes = Util.getPrettyFileSize(restoreProgress.estimatedTotalCount)
Text(
text = stringResource(id = R.string.RemoteRestoreActivity__s_of_s_s, progressBytes, totalBytes, "%.2f%%".format(restoreProgress.getProgress())),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 12.dp)
)
}
}
}
},
modifier = Modifier.width(212.dp)
)
}
@Preview
@Composable
private fun ProgressDialogPreview() {
Previews.Preview {
ProgressDialog(RestoreV2Event(RestoreV2Event.Type.PROGRESS_RESTORE, 10, 1000))
}
}
@Preview
@Composable
private fun RestoreFromBackupContentPreview() {
Previews.Preview {
RestoreFromBackupContent(
features = persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "Your last 30 days of media"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_recent_compact_bold_16,
label = "All of your text messages"
)
),
onRestoreBackupClick = {},
onCancelClick = {},
onMoreOptionsClick = {},
MessageBackupTier.PAID,
System.currentTimeMillis(),
true
)
}
}
@Composable
private fun RestoreFromBackupContent(
features: ImmutableList<MessageBackupsTypeFeature>,
onRestoreBackupClick: () -> Unit,
onCancelClick: () -> Unit,
onMoreOptionsClick: () -> Unit,
tier: MessageBackupTier?,
lastBackupTime: Long,
cancelable: Boolean
) {
Column(
modifier = Modifier
.padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
.padding(top = 40.dp, bottom = 24.dp)
) {
Text(
text = stringResource(id = R.string.RemoteRestoreActivity__restore_from_backup),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 12.dp)
)
val yourLastBackupText = buildAnnotatedString {
append(
stringResource(
id = R.string.RemoteRestoreActivity__backup_created_at,
DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), lastBackupTime),
DateUtils.getOnlyTimeString(LocalContext.current, lastBackupTime)
)
)
append(" ")
if (tier != MessageBackupTier.PAID) {
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
append(stringResource(id = R.string.RemoteRestoreActivity__only_media_sent_or_received))
}
}
}
Text(
text = yourLastBackupText,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 28.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(18.dp))
.padding(horizontal = 20.dp)
.padding(top = 20.dp, bottom = 18.dp)
) {
Text(
text = stringResource(id = R.string.RemoteRestoreActivity__your_backup_includes),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 6.dp)
)
features.forEach {
MessageBackupsTypeFeatureRow(
messageBackupsTypeFeature = it,
iconTint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, top = 6.dp)
)
}
}
Spacer(modifier = Modifier.weight(1f))
Buttons.LargeTonal(
onClick = onRestoreBackupClick,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(id = R.string.RemoteRestoreActivity__restore_backup)
)
}
if (cancelable) {
TextButton(
onClick = onCancelClick,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(id = android.R.string.cancel)
)
}
} else {
TextButton(
onClick = onMoreOptionsClick,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(id = R.string.TransferOrRestoreFragment__more_options)
)
}
}
}
}
private fun restoreFromServer() {
viewModel.restore()
}
private fun continueRegistration() {
if (Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(this, Recipient.self().id)) {
val main = MainActivity.clearTop(this)
val profile = CreateProfileActivity.getIntentForUserProfile(this)
profile.putExtra("next_intent", main)
startActivity(profile)
} else {
RegistrationUtil.maybeMarkRegistrationComplete()
AppDependencies.jobManager.add(ProfileUploadJob())
startActivity(MainActivity.clearTop(this))
}
finish()
}
@Composable
private fun StateLabel(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center
)
}
}

View file

@ -83,7 +83,7 @@ class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome)
} else {
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED)
val restoreIntent = RestoreActivity.getIntentForTransferOrRestore(requireActivity())
val restoreIntent = RestoreActivity.getRestoreIntent(requireActivity())
launchRestoreActivity.launch(restoreIntent)
}
}

View file

@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.jobs.StorageSyncJob;
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.RemoteConfig;
public final class RegistrationUtil {
@ -29,7 +30,8 @@ public final class RegistrationUtil {
if (!SignalStore.registration().isRegistrationComplete() &&
SignalStore.account().isRegistered() &&
!Recipient.self().getProfileName().isEmpty() &&
(SignalStore.svr().hasPin() || SignalStore.svr().hasOptedOut()))
(SignalStore.svr().hasOptedInWithAccess() || SignalStore.svr().hasOptedOut()) &&
(RemoteConfig.restoreAfterRegistration() && (SignalStore.registration().hasSkippedTransferOrRestore() || SignalStore.registration().hasCompletedRestore())))
{
Log.i(TAG, "Marking registration completed.", new Throwable());
SignalStore.registration().setRegistrationComplete();

View file

@ -0,0 +1,105 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.data
import android.net.Uri
import org.signal.core.util.Base64.decode
import org.signal.core.util.Hex
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.InvalidKeyException
import org.signal.libsignal.protocol.ecc.Curve
import org.signal.registration.proto.RegistrationProvisionMessage
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import java.io.IOException
/**
* Helpers for quickly re-registering on a new device with the old device.
*/
object QuickRegistrationRepository {
private val TAG = Log.tag(QuickRegistrationRepository::class)
private const val REREG_URI_HOST = "rereg"
fun isValidReRegistrationQr(data: String): Boolean {
val uri = Uri.parse(data)
if (!uri.isHierarchical) {
return false
}
val ephemeralId: String? = uri.getQueryParameter("uuid")
val publicKeyEncoded: String? = uri.getQueryParameter("pub_key")
return uri.host == REREG_URI_HOST && ephemeralId.isNotNullOrBlank() && publicKeyEncoded.isNotNullOrBlank()
}
/**
* Send registration provisioning message to new device.
*/
fun transferAccount(reRegisterUri: String): TransferAccountResult {
if (!isValidReRegistrationQr(reRegisterUri)) {
Log.w(TAG, "Invalid quick re-register qr data")
return TransferAccountResult.FAILED
}
val uri = Uri.parse(reRegisterUri)
try {
val ephemeralId: String? = uri.getQueryParameter("uuid")
val publicKeyEncoded: String? = uri.getQueryParameter("pub_key")
val publicKey = Curve.decodePoint(publicKeyEncoded?.let { decode(it) }, 0)
if (ephemeralId == null || publicKeyEncoded == null) {
Log.w(TAG, "Invalid link data hasId: ${ephemeralId != null} hasKey: ${publicKeyEncoded != null}")
return TransferAccountResult.FAILED
}
val pin = SignalStore.svr.pin ?: run {
Log.w(TAG, "No pin")
return TransferAccountResult.FAILED
}
AppDependencies
.signalServiceAccountManager
.registrationApi
.sendReRegisterDeviceProvisioningMessage(
ephemeralId,
publicKey,
RegistrationProvisionMessage(
e164 = SignalStore.account.requireE164(),
aci = SignalStore.account.requireAci().toByteString(),
accountEntropyPool = Hex.toStringCondensed(SignalStore.svr.masterKey.serialize()),
pin = pin,
platform = RegistrationProvisionMessage.Platform.ANDROID,
backupTimestampMs = SignalStore.backup.lastBackupTime.coerceAtLeast(0L),
tier = when (SignalStore.backup.backupTier) {
MessageBackupTier.PAID -> RegistrationProvisionMessage.Tier.PAID
MessageBackupTier.FREE,
null -> RegistrationProvisionMessage.Tier.FREE
}
)
)
.successOrThrow()
Log.i(TAG, "Re-registration provisioning message sent")
} catch (e: IOException) {
Log.w(TAG, "Exception re-registering new device", e)
return TransferAccountResult.FAILED
} catch (e: InvalidKeyException) {
Log.w(TAG, "Exception re-registering new device", e)
return TransferAccountResult.FAILED
}
return TransferAccountResult.SUCCESS
}
enum class TransferAccountResult {
SUCCESS,
FAILED
}
}

View file

@ -0,0 +1,623 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.data
import android.app.backup.BackupManager
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationManagerCompat
import com.google.android.gms.auth.api.phone.SmsRetriever
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.signal.core.util.Base64
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.util.KeyHelper
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.AppCapabilities
import org.thoughtcrime.securesms.crypto.PreKeyUtil
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.crypto.SenderKeyUtil
import org.thoughtcrime.securesms.crypto.storage.PreKeyMetadataStore
import org.thoughtcrime.securesms.crypto.storage.SignalServiceAccountDataStoreImpl
import org.thoughtcrime.securesms.database.IdentityTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.gcm.FcmUtil
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.NotificationIds
import org.thoughtcrime.securesms.pin.Svr3Migration
import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.pin.SvrWrongPinException
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.push.AccountManagerFactory
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getAciIdentityKeyPair
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getAciPreKeyCollection
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getPniIdentityKeyPair
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getPniPreKeyCollection
import org.thoughtcrime.securesms.registration.data.RegistrationData
import org.thoughtcrime.securesms.registration.data.network.BackupAuthCheckResult
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCheckResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.fcm.PushChallengeRequest
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.service.DirectoryRefreshListener
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.account.AccountAttributes
import org.whispersystems.signalservice.api.account.PreKeyCollection
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
import org.whispersystems.signalservice.api.kbs.MasterKey
import org.whispersystems.signalservice.api.kbs.PinHashUtil
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.api.registration.RegistrationApi
import org.whispersystems.signalservice.api.svr.Svr3Credentials
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataHeaders
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import java.io.IOException
import java.nio.charset.StandardCharsets
import java.util.Locale
import java.util.Optional
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.seconds
/**
* A repository that deals with disk I/O during account registration.
*/
object RegistrationRepository {
private val TAG = Log.tag(RegistrationRepository::class.java)
private val PUSH_REQUEST_TIMEOUT = 5.seconds.inWholeMilliseconds
/**
* Retrieve the FCM token from the Firebase service.
*/
suspend fun getFcmToken(context: Context): String? =
withContext(Dispatchers.Default) {
FcmUtil.getToken(context).orElse(null)
}
/**
* Queries, and creates if needed, the local registration ID.
*/
@JvmStatic
fun getRegistrationId(): Int {
// TODO [regv2]: make creation more explicit instead of hiding it in this getter
var registrationId = SignalStore.account.registrationId
if (registrationId == 0) {
registrationId = KeyHelper.generateRegistrationId(false)
SignalStore.account.registrationId = registrationId
}
return registrationId
}
/**
* Queries, and creates if needed, the local PNI registration ID.
*/
@JvmStatic
fun getPniRegistrationId(): Int {
// TODO [regv2]: make creation more explicit instead of hiding it in this getter
var pniRegistrationId = SignalStore.account.pniRegistrationId
if (pniRegistrationId == 0) {
pniRegistrationId = KeyHelper.generateRegistrationId(false)
SignalStore.account.pniRegistrationId = pniRegistrationId
}
return pniRegistrationId
}
/**
* Queries, and creates if needed, the local profile key.
*/
@JvmStatic
suspend fun getProfileKey(e164: String): ProfileKey =
withContext(Dispatchers.IO) {
// TODO [regv2]: make creation more explicit instead of hiding it in this getter
val recipientTable = SignalDatabase.recipients
val recipient = recipientTable.getByE164(e164)
var profileKey = if (recipient.isPresent) {
ProfileKeyUtil.profileKeyOrNull(Recipient.resolved(recipient.get()).profileKey)
} else {
null
}
if (profileKey == null) {
profileKey = ProfileKeyUtil.createNew()
Log.i(TAG, "No profile key found, created a new one")
}
profileKey
}
/**
* Takes a server response from a successful registration and persists the relevant data.
*/
@JvmStatic
suspend fun registerAccountLocally(context: Context, data: LocalRegistrationMetadata) =
withContext(Dispatchers.IO) {
Log.v(TAG, "registerAccountLocally()")
val aciIdentityKeyPair = data.getAciIdentityKeyPair()
val pniIdentityKeyPair = data.getPniIdentityKeyPair()
SignalStore.account.restoreAciIdentityKeyFromBackup(aciIdentityKeyPair.publicKey.serialize(), aciIdentityKeyPair.privateKey.serialize())
SignalStore.account.restorePniIdentityKeyFromBackup(pniIdentityKeyPair.publicKey.serialize(), pniIdentityKeyPair.privateKey.serialize())
val aciPreKeyCollection = data.getAciPreKeyCollection()
val pniPreKeyCollection = data.getPniPreKeyCollection()
val aci: ACI = ACI.parseOrThrow(data.aci)
val pni: PNI = PNI.parseOrThrow(data.pni)
val hasPin: Boolean = data.hasPin
SignalStore.account.setAci(aci)
SignalStore.account.setPni(pni)
AppDependencies.resetProtocolStores()
AppDependencies.protocolStore.aci().sessions().archiveAllSessions()
AppDependencies.protocolStore.pni().sessions().archiveAllSessions()
SenderKeyUtil.clearAllState()
val aciProtocolStore = AppDependencies.protocolStore.aci()
val aciMetadataStore = SignalStore.account.aciPreKeys
val pniProtocolStore = AppDependencies.protocolStore.pni()
val pniMetadataStore = SignalStore.account.pniPreKeys
storeSignedAndLastResortPreKeys(aciProtocolStore, aciMetadataStore, aciPreKeyCollection)
storeSignedAndLastResortPreKeys(pniProtocolStore, pniMetadataStore, pniPreKeyCollection)
val recipientTable = SignalDatabase.recipients
val selfId = Recipient.trustedPush(aci, pni, data.e164).id
recipientTable.setProfileSharing(selfId, true)
recipientTable.markRegisteredOrThrow(selfId, aci)
recipientTable.linkIdsForSelf(aci, pni, data.e164)
recipientTable.setProfileKey(selfId, ProfileKey(data.profileKey.toByteArray()))
AppDependencies.recipientCache.clearSelf()
SignalStore.account.setE164(data.e164)
SignalStore.account.fcmToken = data.fcmToken
SignalStore.account.fcmEnabled = data.fcmEnabled
val now = System.currentTimeMillis()
saveOwnIdentityKey(selfId, aci, aciProtocolStore, now)
saveOwnIdentityKey(selfId, pni, pniProtocolStore, now)
SignalStore.account.setServicePassword(data.servicePassword)
SignalStore.account.setRegistered(true)
TextSecurePreferences.setPromptedPushRegistration(context, true)
TextSecurePreferences.setUnauthorizedReceived(context, false)
NotificationManagerCompat.from(context).cancel(NotificationIds.UNREGISTERED_NOTIFICATION_ID)
val masterKey = if (data.masterKey != null) MasterKey(data.masterKey.toByteArray()) else null
SvrRepository.onRegistrationComplete(masterKey, data.pin, hasPin, data.reglockEnabled)
AppDependencies.resetNetwork()
AppDependencies.incomingMessageObserver
PreKeysSyncJob.enqueue()
val jobManager = AppDependencies.jobManager
jobManager.add(DirectoryRefreshJob(false))
jobManager.add(RotateCertificateJob())
DirectoryRefreshListener.schedule(context)
RotateSignedPreKeyListener.schedule(context)
}
@JvmStatic
private fun saveOwnIdentityKey(selfId: RecipientId, serviceId: ServiceId, protocolStore: SignalServiceAccountDataStoreImpl, now: Long) {
protocolStore.identities().saveIdentityWithoutSideEffects(
selfId,
serviceId,
protocolStore.identityKeyPair.publicKey,
IdentityTable.VerifiedStatus.VERIFIED,
true,
now,
true
)
}
@JvmStatic
private fun storeSignedAndLastResortPreKeys(protocolStore: SignalServiceAccountDataStoreImpl, metadataStore: PreKeyMetadataStore, preKeyCollection: PreKeyCollection) {
PreKeyUtil.storeSignedPreKey(protocolStore, metadataStore, preKeyCollection.signedPreKey)
metadataStore.isSignedPreKeyRegistered = true
metadataStore.activeSignedPreKeyId = preKeyCollection.signedPreKey.id
metadataStore.lastSignedPreKeyRotationTime = System.currentTimeMillis()
PreKeyUtil.storeLastResortKyberPreKey(protocolStore, metadataStore, preKeyCollection.lastResortKyberPreKey)
metadataStore.lastResortKyberPreKeyId = preKeyCollection.lastResortKyberPreKey.id
metadataStore.lastResortKyberPreKeyRotationTime = System.currentTimeMillis()
}
fun canUseLocalRecoveryPassword(): Boolean {
val recoveryPassword = SignalStore.svr.recoveryPassword
val pinHash = SignalStore.svr.localPinHash
return recoveryPassword != null && pinHash != null
}
fun doesPinMatchLocalHash(pin: String): Boolean {
val pinHash = SignalStore.svr.localPinHash ?: throw IllegalStateException("Local PIN hash is not present!")
return PinHashUtil.verifyLocalPinHash(pinHash, pin)
}
suspend fun fetchMasterKeyFromSvrRemote(pin: String, svr2Credentials: AuthCredentials?, svr3Credentials: Svr3Credentials?): MasterKey =
withContext(Dispatchers.IO) {
val credentialSet = SvrAuthCredentialSet(svr2Credentials = svr2Credentials, svr3Credentials = svr3Credentials)
val masterKey = SvrRepository.restoreMasterKeyPreRegistration(credentialSet, pin)
return@withContext masterKey
}
/**
* Validates a session ID.
*/
private suspend fun validateSession(context: Context, sessionId: String, e164: String, password: String): RegistrationSessionCheckResult =
withContext(Dispatchers.IO) {
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
Log.d(TAG, "Validating registration session with service.")
val registrationSessionResult = api.getRegistrationSessionStatus(sessionId)
return@withContext RegistrationSessionCheckResult.from(registrationSessionResult)
}
/**
* Initiates a new registration session on the service.
*/
suspend fun createSession(context: Context, e164: String, password: String, mcc: String?, mnc: String?): RegistrationSessionCreationResult =
withContext(Dispatchers.IO) {
Log.d(TAG, "About to create a registration session…")
val fcmToken: String? = FcmUtil.getToken(context).orElse(null)
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
val registrationSessionResult = if (fcmToken == null) {
Log.d(TAG, "Creating registration session without FCM token.")
api.createRegistrationSession(null, mcc, mnc)
} else {
Log.d(TAG, "Creating registration session with FCM token.")
createSessionAndBlockForPushChallenge(api, fcmToken, mcc, mnc)
}
val result = RegistrationSessionCreationResult.from(registrationSessionResult)
if (result is RegistrationSessionCreationResult.Success) {
Log.d(TAG, "Updating registration session and E164 in value store.")
SignalStore.registration.sessionId = result.getMetadata().body.id
SignalStore.registration.sessionE164 = e164
}
return@withContext result
}
/**
* Validates an existing session, if its ID is provided. If the session is expired/invalid, or none is provided, it will attempt to initiate a new session.
*/
suspend fun createOrValidateSession(context: Context, sessionId: String?, e164: String, password: String, mcc: String?, mnc: String?): RegistrationSessionResult {
val savedSessionId = if (sessionId == null && e164 == SignalStore.registration.sessionE164) {
SignalStore.registration.sessionId
} else {
sessionId
}
if (savedSessionId != null) {
Log.d(TAG, "Validating existing registration session.")
val sessionValidationResult = validateSession(context, savedSessionId, e164, password)
when (sessionValidationResult) {
is RegistrationSessionCheckResult.Success -> {
Log.d(TAG, "Existing registration session is valid.")
return sessionValidationResult
}
is RegistrationSessionCheckResult.UnknownError -> {
Log.w(TAG, "Encountered error when validating existing session.", sessionValidationResult.getCause())
return sessionValidationResult
}
is RegistrationSessionCheckResult.SessionNotFound -> {
Log.i(TAG, "Current session is invalid or has expired. Must create new one.")
// fall through to creation
}
}
}
return createSession(context, e164, password, mcc, mnc)
}
/**
* Asks the service to send a verification code through one of our supported channels (SMS, phone call).
*/
suspend fun requestSmsCode(context: Context, sessionId: String, e164: String, password: String, mode: E164VerificationMode): VerificationCodeRequestResult =
withContext(Dispatchers.IO) {
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
val codeRequestResult = api.requestSmsVerificationCode(sessionId, Locale.getDefault(), mode.isSmsRetrieverSupported, mode.transport)
return@withContext VerificationCodeRequestResult.from(codeRequestResult)
}
/**
* Submits the user-entered verification code to the service.
*/
suspend fun submitVerificationCode(context: Context, sessionId: String, registrationData: RegistrationData): VerificationCodeRequestResult =
withContext(Dispatchers.IO) {
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, registrationData.e164, SignalServiceAddress.DEFAULT_DEVICE_ID, registrationData.password).registrationApi
val result = api.verifyAccount(sessionId = sessionId, verificationCode = registrationData.code)
return@withContext VerificationCodeRequestResult.from(result)
}
/**
* Submits the solved captcha token to the service.
*/
suspend fun submitCaptchaToken(context: Context, e164: String, password: String, sessionId: String, captchaToken: String): VerificationCodeRequestResult =
withContext(Dispatchers.IO) {
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
val captchaSubmissionResult = api.submitCaptchaToken(sessionId = sessionId, captchaToken = captchaToken)
return@withContext VerificationCodeRequestResult.from(captchaSubmissionResult)
}
suspend fun requestAndVerifyPushToken(context: Context, sessionId: String, e164: String, password: String) =
withContext(Dispatchers.IO) {
val fcmToken = getFcmToken(context)
val accountManager = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password)
val pushChallenge = PushChallengeRequest.getPushChallengeBlocking(accountManager, sessionId, Optional.ofNullable(fcmToken), PUSH_REQUEST_TIMEOUT).orElse(null)
val pushSubmissionResult = accountManager.registrationApi.submitPushChallengeToken(sessionId = sessionId, pushChallengeToken = pushChallenge)
return@withContext VerificationCodeRequestResult.from(pushSubmissionResult)
}
/**
* Submit the necessary assets as a verified account so that the user can actually use the service.
*/
suspend fun registerAccount(context: Context, sessionId: String?, registrationData: RegistrationData, pin: String? = null, masterKeyProducer: MasterKeyProducer? = null): RegisterAccountResult =
withContext(Dispatchers.IO) {
Log.v(TAG, "registerAccount()")
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, registrationData.e164, SignalServiceAddress.DEFAULT_DEVICE_ID, registrationData.password).registrationApi
val universalUnidentifiedAccess: Boolean = TextSecurePreferences.isUniversalUnidentifiedAccess(context)
val unidentifiedAccessKey: ByteArray = UnidentifiedAccess.deriveAccessKeyFrom(registrationData.profileKey)
val masterKey: MasterKey?
try {
masterKey = masterKeyProducer?.produceMasterKey()
} catch (e: SvrNoDataException) {
return@withContext RegisterAccountResult.SvrNoData(e)
} catch (e: SvrWrongPinException) {
return@withContext RegisterAccountResult.SvrWrongPin(e)
} catch (e: IOException) {
return@withContext RegisterAccountResult.UnknownError(e)
}
val registrationLock: String? = masterKey?.deriveRegistrationLock()
val accountAttributes = AccountAttributes(
signalingKey = null,
registrationId = registrationData.registrationId,
fetchesMessages = registrationData.isNotFcm,
registrationLock = registrationLock,
unidentifiedAccessKey = unidentifiedAccessKey,
unrestrictedUnidentifiedAccess = universalUnidentifiedAccess,
capabilities = AppCapabilities.getCapabilities(true),
discoverableByPhoneNumber = SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode == PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.DISCOVERABLE,
name = null,
pniRegistrationId = registrationData.pniRegistrationId,
recoveryPassword = registrationData.recoveryPassword
)
SignalStore.account.generateAciIdentityKeyIfNecessary()
val aciIdentity: IdentityKeyPair = SignalStore.account.aciIdentityKey
SignalStore.account.generatePniIdentityKeyIfNecessary()
val pniIdentity: IdentityKeyPair = SignalStore.account.pniIdentityKey
val aciPreKeyCollection = generateSignedAndLastResortPreKeys(aciIdentity, SignalStore.account.aciPreKeys)
val pniPreKeyCollection = generateSignedAndLastResortPreKeys(pniIdentity, SignalStore.account.pniPreKeys)
val result: NetworkResult<AccountRegistrationResult> = api.registerAccount(sessionId, registrationData.recoveryPassword, accountAttributes, aciPreKeyCollection, pniPreKeyCollection, registrationData.fcmToken, true)
.map { accountRegistrationResponse: VerifyAccountResponse ->
AccountRegistrationResult(
uuid = accountRegistrationResponse.uuid,
pni = accountRegistrationResponse.pni,
storageCapable = accountRegistrationResponse.storageCapable,
number = accountRegistrationResponse.number,
masterKey = masterKey,
pin = pin,
aciPreKeyCollection = aciPreKeyCollection,
pniPreKeyCollection = pniPreKeyCollection
)
}
return@withContext RegisterAccountResult.from(result)
}
private suspend fun createSessionAndBlockForPushChallenge(accountManager: RegistrationApi, fcmToken: String, mcc: String?, mnc: String?): NetworkResult<RegistrationSessionMetadataResponse> =
withContext(Dispatchers.IO) {
// TODO [regv2]: do not use event bus nor latch
val subscriber = PushTokenChallengeSubscriber()
val eventBus = EventBus.getDefault()
eventBus.register(subscriber)
try {
Log.d(TAG, "Requesting a registration session with FCM token…")
val sessionCreationResponse = accountManager.createRegistrationSession(fcmToken, mcc, mnc)
if (sessionCreationResponse !is NetworkResult.Success) {
return@withContext sessionCreationResponse
}
val receivedPush = subscriber.latch.await(PUSH_REQUEST_TIMEOUT, TimeUnit.MILLISECONDS)
eventBus.unregister(subscriber)
if (receivedPush) {
val challenge = subscriber.challenge
if (challenge != null) {
Log.w(TAG, "Push challenge token received.")
return@withContext accountManager.submitPushChallengeToken(sessionCreationResponse.result.body.id, challenge)
} else {
Log.w(TAG, "Push received but challenge token was null.")
}
} else {
Log.i(TAG, "Push challenge timed out.")
}
Log.i(TAG, "Push challenge unsuccessful. Continuing with session created without one.")
return@withContext sessionCreationResponse
} catch (ex: Exception) {
Log.w(TAG, "Exception caught, but the earlier try block should have caught it?", ex)
return@withContext NetworkResult.ApplicationError<RegistrationSessionMetadataResponse>(ex)
}
}
@JvmStatic
fun deriveTimestamp(headers: RegistrationSessionMetadataHeaders, deltaSeconds: Int?): Long {
if (deltaSeconds == null) {
return 0L
}
val timestamp: Long = headers.timestamp
return timestamp + deltaSeconds.seconds.inWholeMilliseconds
}
suspend fun hasValidSvrAuthCredentials(context: Context, e164: String, password: String): BackupAuthCheckResult =
withContext(Dispatchers.IO) {
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
val svr3Result = SignalStore.svr.svr3AuthTokens
?.takeIf { Svr3Migration.shouldReadFromSvr3 }
?.takeIf { it.isNotEmpty() }
?.toSvrCredentials()
?.let { authTokens ->
api
.validateSvr3AuthCredential(e164, authTokens)
.runIfSuccessful {
val removedInvalidTokens = SignalStore.svr.removeSvr3AuthTokens(it.invalid)
if (removedInvalidTokens) {
BackupManager(context).dataChanged()
}
}
.let { BackupAuthCheckResult.fromV3(it) }
}
if (svr3Result is BackupAuthCheckResult.SuccessWithCredentials) {
Log.d(TAG, "Found valid SVR3 credentials.")
return@withContext svr3Result
}
Log.d(TAG, "No valid SVR3 credentials, looking for SVR2.")
return@withContext SignalStore.svr.svr2AuthTokens
?.takeIf { it.isNotEmpty() }
?.toSvrCredentials()
?.let { authTokens ->
api
.validateSvr2AuthCredential(e164, authTokens)
.runIfSuccessful {
val removedInvalidTokens = SignalStore.svr.removeSvr2AuthTokens(it.invalid)
if (removedInvalidTokens) {
BackupManager(context).dataChanged()
}
}
.let { BackupAuthCheckResult.fromV2(it) }
} ?: BackupAuthCheckResult.SuccessWithoutCredentials()
}
/** Converts the basic-auth creds we have locally into username:password pairs that are suitable for handing off to the service. */
private fun List<String?>.toSvrCredentials(): List<String> {
return this
.asSequence()
.filterNotNull()
.take(10)
.map { it.replace("Basic ", "").trim() }
.mapNotNull {
try {
Base64.decode(it)
} catch (e: IOException) {
Log.w(TAG, "Encountered error trying to decode a token!", e)
null
}
}
.map { String(it, StandardCharsets.ISO_8859_1) }
.toList()
}
/**
* Starts an SMS listener to auto-enter a verification code.
*
* The listener [lives for 5 minutes](https://developers.google.com/android/reference/com/google/android/gms/auth/api/phone/SmsRetrieverApi).
*
* @return whether or not the Play Services SMS Listener was successfully registered.
*/
suspend fun registerSmsListener(context: Context): Boolean {
Log.d(TAG, "Attempting to start verification code SMS retriever.")
val started = withTimeoutOrNull(5.seconds.inWholeMilliseconds) {
try {
SmsRetriever.getClient(context).startSmsRetriever().await()
Log.d(TAG, "Successfully started verification code SMS retriever.")
return@withTimeoutOrNull true
} catch (ex: Exception) {
Log.w(TAG, "Could not start verification code SMS retriever due to exception.", ex)
return@withTimeoutOrNull false
}
}
if (started == null) {
Log.w(TAG, "Could not start verification code SMS retriever due to timeout.")
}
return started == true
}
@VisibleForTesting
fun generateSignedAndLastResortPreKeys(identity: IdentityKeyPair, metadataStore: PreKeyMetadataStore): PreKeyCollection {
val signedPreKey = PreKeyUtil.generateSignedPreKey(metadataStore.nextSignedPreKeyId, identity.privateKey)
val lastResortKyberPreKey = PreKeyUtil.generateLastResortKyberPreKey(metadataStore.nextKyberPreKeyId, identity.privateKey)
return PreKeyCollection(
identity.publicKey,
signedPreKey,
lastResortKyberPreKey
)
}
fun isMissingProfileData(): Boolean {
return Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(AppDependencies.application, Recipient.self().id)
}
fun interface MasterKeyProducer {
@Throws(IOException::class, SvrWrongPinException::class, SvrNoDataException::class)
fun produceMasterKey(): MasterKey
}
enum class E164VerificationMode(val isSmsRetrieverSupported: Boolean, val transport: PushServiceSocket.VerificationCodeTransport) {
SMS_WITH_LISTENER(true, PushServiceSocket.VerificationCodeTransport.SMS),
SMS_WITHOUT_LISTENER(false, PushServiceSocket.VerificationCodeTransport.SMS),
PHONE_CALL(false, PushServiceSocket.VerificationCodeTransport.VOICE)
}
private class PushTokenChallengeSubscriber {
var challenge: String? = null
val latch = CountDownLatch(1)
@Subscribe
fun onChallengeEvent(pushChallengeEvent: PushChallengeRequest.PushChallengeEvent) {
Log.d(TAG, "Push challenge received!")
challenge = pushChallengeEvent.challenge
latch.countDown()
}
}
}

View file

@ -0,0 +1,346 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.olddevice
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.Texts
import org.signal.core.ui.horizontalGutters
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BiometricDeviceAuthentication
import org.thoughtcrime.securesms.BiometricDeviceLockContract
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.fonts.SignalSymbols
import org.thoughtcrime.securesms.fonts.SignalSymbols.SignalSymbol
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.viewModel
/**
* Launched after scanning QR code from new device to start the transfer/reregistration process from
* old phone to new phone.
*/
class TransferAccountActivity : PassphraseRequiredActivity() {
companion object {
private val TAG = Log.tag(TransferAccountActivity::class)
private const val KEY_URI = "URI"
// TODO [backups] Put actual learn more url
const val LEARN_MORE_URL = "https://signal.org#"
fun intent(context: Context, uri: String): Intent {
return Intent(context, TransferAccountActivity::class.java).apply {
putExtra(KEY_URI, uri)
}
}
}
private val theme: DynamicTheme = DynamicNoActionBarTheme()
private val viewModel: TransferAccountViewModel by viewModel {
TransferAccountViewModel(intent.getStringExtra(KEY_URI)!!)
}
private lateinit var biometricAuth: BiometricDeviceAuthentication
private lateinit var biometricDeviceLockLauncher: ActivityResultLauncher<String>
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
theme.onCreate(this)
if (!SignalStore.account.isRegistered) {
finish()
}
biometricDeviceLockLauncher = registerForActivityResult(BiometricDeviceLockContract()) { result: Int ->
if (result == BiometricDeviceAuthentication.AUTHENTICATED) {
Log.i(TAG, "Device authentication succeeded via contract")
transferAccount()
}
}
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
.setTitle(getString(R.string.TransferAccount_unlock_to_transfer))
.setConfirmationRequired(true)
.build()
biometricAuth = BiometricDeviceAuthentication(
BiometricManager.from(this),
BiometricPrompt(this, BiometricAuthenticationListener()),
promptInfo
)
setContent {
val state by viewModel.state.collectAsState()
SignalTheme {
TransferToNewDevice(
state = state,
onTransferAccount = this::authenticate,
clearReRegisterResult = viewModel::clearReRegisterResult,
onBackClicked = { finish() }
)
}
}
}
override fun onPause() {
super.onPause()
biometricAuth.cancelAuthentication()
}
override fun onResume() {
super.onResume()
theme.onResume(this)
}
private fun authenticate() {
val canAuthenticate = biometricAuth.authenticate(this, true) {
biometricDeviceLockLauncher.launch(getString(R.string.TransferAccount_unlock_to_transfer))
}
if (!canAuthenticate) {
Log.w(TAG, "Device authentication not available")
transferAccount()
}
}
private fun transferAccount() {
Log.d(TAG, "transferAccount()")
viewModel.transferAccount()
}
private inner class BiometricAuthenticationListener : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errorString: CharSequence) {
Log.w(TAG, "Device authentication error: $errorCode")
onAuthenticationFailed()
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
Log.i(TAG, "Device authentication succeeded")
transferAccount()
}
override fun onAuthenticationFailed() {
Log.w(TAG, "Device authentication failed")
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransferToNewDevice(
state: TransferAccountViewModel.TransferAccountState,
onTransferAccount: () -> Unit = {},
clearReRegisterResult: () -> Unit = {},
onBackClicked: () -> Unit = {}
) {
Scaffold(
topBar = { TopAppBarContent(onBackClicked = onBackClicked) }
) { contentPadding ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(contentPadding)
.horizontalGutters()
) {
Image(
painter = painterResource(R.drawable.image_transfer_phones),
contentDescription = null,
modifier = Modifier.padding(top = 20.dp, bottom = 28.dp)
)
val context = LocalContext.current
val learnMore = stringResource(id = R.string.TransferAccount_learn_more)
val fullString = stringResource(id = R.string.TransferAccount_body, learnMore)
val spanned = SpanUtil.urlSubsequence(fullString, learnMore, TransferAccountActivity.LEARN_MORE_URL)
Texts.LinkifiedText(
textWithUrlSpans = spanned,
onUrlClick = { CommunicationActions.openBrowserLink(context, it) },
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center)
)
Spacer(modifier = Modifier.height(28.dp))
AnimatedContent(
targetState = state.inProgress,
contentAlignment = Alignment.Center
) { inProgress ->
if (inProgress) {
CircularProgressIndicator()
} else {
Buttons.LargeTonal(
onClick = onTransferAccount,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.TransferAccount_button))
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = buildAnnotatedString {
SignalSymbol(SignalSymbols.Weight.REGULAR, SignalSymbols.Glyph.LOCK)
append(" ")
append(stringResource(id = R.string.TransferAccount_messages_e2e))
},
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
)
}
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
when (state.reRegisterResult) {
QuickRegistrationRepository.TransferAccountResult.SUCCESS -> {
ModalBottomSheet(
dragHandle = null,
onDismissRequest = clearReRegisterResult,
sheetState = sheetState
) {
ContinueOnOtherDevice()
}
}
QuickRegistrationRepository.TransferAccountResult.FAILED -> {
Dialogs.SimpleAlertDialog(
title = Dialogs.NoTitle,
body = stringResource(R.string.RegistrationActivity_unable_to_connect_to_service),
confirm = stringResource(android.R.string.ok),
onConfirm = clearReRegisterResult,
onDismiss = clearReRegisterResult
)
}
null -> Unit
}
}
}
@SignalPreview
@Composable
private fun TransferToNewDevicePreview() {
Previews.Preview {
TransferToNewDevice(state = TransferAccountViewModel.TransferAccountState("sgnl://rereg"))
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopAppBarContent(onBackClicked: () -> Unit) {
TopAppBar(
title = {
Text(text = stringResource(R.string.TransferAccount_title))
},
navigationIcon = {
IconButton(onClick = onBackClicked) {
Icon(
painter = painterResource(R.drawable.symbol_x_24),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null
)
}
}
)
}
/**
* Shown after successfully sending provisioning message to new device.
*/
@Composable
fun ContinueOnOtherDevice() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
.padding(bottom = 54.dp)
) {
BottomSheets.Handle()
Spacer(modifier = Modifier.height(26.dp))
Image(
painter = painterResource(R.drawable.image_other_device),
contentDescription = null,
modifier = Modifier.padding(bottom = 20.dp)
)
Text(
text = stringResource(id = R.string.TransferAccount_continue_on_your_other_device),
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(id = R.string.TransferAccount_continue_on_your_other_device_details),
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
)
Spacer(modifier = Modifier.height(36.dp))
CircularProgressIndicator(modifier = Modifier.size(44.dp))
}
}
@SignalPreview
@Composable
private fun ContinueOnOtherDevicePreview() {
Previews.Preview {
ContinueOnOtherDevice()
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.olddevice
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository
class TransferAccountViewModel(reRegisterUri: String) : ViewModel() {
private val store: MutableStateFlow<TransferAccountState> = MutableStateFlow(TransferAccountState(reRegisterUri))
val state: StateFlow<TransferAccountState> = store
fun transferAccount() {
viewModelScope.launch(Dispatchers.IO) {
store.update { it.copy(inProgress = true) }
val result = QuickRegistrationRepository.transferAccount(store.value.reRegisterUri)
store.update { it.copy(reRegisterResult = result, inProgress = false) }
}
}
fun clearReRegisterResult() {
store.update { it.copy(reRegisterResult = null) }
}
data class TransferAccountState(
val reRegisterUri: String,
val inProgress: Boolean = false,
val reRegisterResult: QuickRegistrationRepository.TransferAccountResult? = null
)
}

View file

@ -0,0 +1,138 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.navigation.ActivityNavigator
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.pin.PinRestoreActivity
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.sms.SmsRetrieverReceiver
import org.thoughtcrime.securesms.registrationv3.ui.restore.RemoteRestoreActivity
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.RemoteConfig
/**
* Activity to hold the entire registration process.
*/
class RegistrationActivity : BaseActivity() {
private val TAG = Log.tag(RegistrationActivity::class.java)
private val dynamicTheme = DynamicNoActionBarTheme()
val sharedViewModel: RegistrationViewModel by viewModels()
private var smsRetrieverReceiver: SmsRetrieverReceiver? = null
init {
lifecycle.addObserver(SmsRetrieverObserver())
}
override fun onCreate(savedInstanceState: Bundle?) {
dynamicTheme.onCreate(this)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_registration_navigation_v3)
sharedViewModel.isReregister = intent.getBooleanExtra(RE_REGISTRATION_EXTRA, false)
sharedViewModel.checkpoint.observe(this) {
if (it >= RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE) {
handleSuccessfulVerify()
}
}
}
override fun onResume() {
super.onResume()
dynamicTheme.onResume(this)
}
private fun handleSuccessfulVerify() {
if (SignalStore.misc.hasLinkedDevices) {
SignalStore.misc.shouldShowLinkedDevicesReminder = sharedViewModel.isReregister
}
if (SignalStore.storageService.needsAccountRestore()) {
Log.i(TAG, "Performing pin restore.")
startActivity(Intent(this, PinRestoreActivity::class.java))
finish()
} else {
val isProfileNameEmpty = Recipient.self().profileName.isEmpty
val isAvatarEmpty = !AvatarHelper.hasAvatar(this, Recipient.self().id)
val needsProfile = isProfileNameEmpty || isAvatarEmpty
val needsPin = !SignalStore.svr.hasOptedInWithAccess()
Log.i(TAG, "Pin restore flow not required. Profile name empty: $isProfileNameEmpty | Profile avatar empty: $isAvatarEmpty | Needs PIN: $needsPin")
if (!needsProfile && !needsPin) {
sharedViewModel.completeRegistration()
}
sharedViewModel.setInProgress(false)
val startIntent = MainActivity.clearTop(this)
val nextIntent: Intent? = when {
needsPin -> CreateSvrPinActivity.getIntentForPinCreate(this@RegistrationActivity)
!SignalStore.registration.hasSkippedTransferOrRestore() && RemoteConfig.messageBackups -> RemoteRestoreActivity.getIntent(this@RegistrationActivity)
needsProfile -> CreateProfileActivity.getIntentForUserProfile(this@RegistrationActivity)
else -> null
}
if (nextIntent != null) {
startIntent.putExtra("next_intent", nextIntent)
}
Log.d(TAG, "Launching ${startIntent.component} with next_intent: ${nextIntent?.component}")
startActivity(startIntent)
finish()
ActivityNavigator.applyPopAnimationsToPendingTransition(this)
}
}
private inner class SmsRetrieverObserver : DefaultLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) {
smsRetrieverReceiver = SmsRetrieverReceiver(application)
smsRetrieverReceiver?.registerReceiver()
}
override fun onDestroy(owner: LifecycleOwner) {
smsRetrieverReceiver?.unregisterReceiver()
smsRetrieverReceiver = null
}
}
companion object {
const val RE_REGISTRATION_EXTRA: String = "re_registration"
@JvmStatic
fun newIntentForNewRegistration(context: Context, originalIntent: Intent): Intent {
return Intent(context, RegistrationActivity::class.java).apply {
putExtra(RE_REGISTRATION_EXTRA, false)
setData(originalIntent.data)
}
}
@JvmStatic
fun newIntentForReRegistration(context: Context): Intent {
return Intent(context, RegistrationActivity::class.java).apply {
putExtra(RE_REGISTRATION_EXTRA, true)
}
}
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui
/**
* An ordered list of checkpoints of the registration process.
* This is used for screens to know when to advance, as well as restoring state after process death.
*/
enum class RegistrationCheckpoint {
INITIALIZATION,
PERMISSIONS_GRANTED,
BACKUP_RESTORED_OR_SKIPPED,
PUSH_NETWORK_AUDITED,
PHONE_NUMBER_CONFIRMED,
PIN_CONFIRMED,
CHALLENGE_RECEIVED,
CHALLENGE_COMPLETED,
VERIFICATION_CODE_REQUESTED,
VERIFICATION_CODE_ENTERED,
PIN_ENTERED,
VERIFICATION_CODE_VALIDATED,
SERVICE_REGISTRATION_COMPLETED,
LOCAL_REGISTRATION_COMPLETE
}

View file

@ -0,0 +1,75 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import com.google.i18n.phonenumbers.Phonenumber
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.data.network.Challenge
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.whispersystems.signalservice.api.svr.Svr3Credentials
import org.whispersystems.signalservice.internal.push.AuthCredentials
/**
* State holder shared across all of registration.
*/
data class RegistrationState(
val sessionId: String? = null,
val enteredCode: String = "",
val phoneNumber: Phonenumber.PhoneNumber? = fetchExistingE164FromValues(),
val inProgress: Boolean = false,
val isReRegister: Boolean = false,
val recoveryPassword: String? = null,
val canSkipSms: Boolean = false,
val svr2AuthCredentials: AuthCredentials? = null,
val svr3AuthCredentials: Svr3Credentials? = null,
val svrTriesRemaining: Int = 10,
val incorrectCodeAttempts: Int = 0,
val isRegistrationLockEnabled: Boolean = false,
val lockedTimeRemaining: Long = 0L,
val userSkippedReregistration: Boolean = false,
val isFcmSupported: Boolean = false,
val isAllowedToRequestCode: Boolean = false,
val fcmToken: String? = null,
val challengesRequested: List<Challenge> = emptyList(),
val challengesPresented: Set<Challenge> = emptySet(),
val captchaToken: String? = null,
val allowedToRequestCode: Boolean = false,
val nextSmsTimestamp: Long = 0L,
val nextCallTimestamp: Long = 0L,
val nextVerificationAttempt: Long = 0L,
val verified: Boolean = false,
val smsListenerTimeout: Long = 0L,
val registrationCheckpoint: RegistrationCheckpoint = RegistrationCheckpoint.INITIALIZATION,
val networkError: Throwable? = null,
val sessionCreationError: RegistrationSessionResult? = null,
val sessionStateError: VerificationCodeRequestResult? = null,
val registerAccountError: RegisterAccountResult? = null
) {
val challengesRemaining: List<Challenge> = challengesRequested.filterNot { it in challengesPresented }
companion object {
private val TAG = Log.tag(RegistrationState::class)
private fun fetchExistingE164FromValues(): Phonenumber.PhoneNumber? {
val existingE164 = SignalStore.registration.sessionE164
if (existingE164 != null) {
try {
return PhoneNumberUtil.getInstance().parse(existingE164, null)
} catch (ex: NumberParseException) {
Log.w(TAG, "Could not parse stored E164.", ex)
return null
}
} else {
return null
}
}
}
}

View file

@ -0,0 +1,996 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui
import android.Manifest
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.google.i18n.phonenumbers.PhoneNumberUtil
import com.google.i18n.phonenumbers.Phonenumber
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.Hex
import org.signal.core.util.Stopwatch
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.jobs.ReclaimUsernameAndLinkJob
import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob
import org.thoughtcrime.securesms.jobs.StorageSyncJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.pin.SvrWrongPinException
import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil
import org.thoughtcrime.securesms.registration.data.RegistrationData
import org.thoughtcrime.securesms.registration.data.network.BackupAuthCheckResult
import org.thoughtcrime.securesms.registration.data.network.Challenge
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCheckResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.AlreadyVerified
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.AttemptsExhausted
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.ChallengeRequired
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.ExternalServiceFailure
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.ImpossibleNumber
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.InvalidTransportModeFailure
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.MalformedRequest
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.MustRetry
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.NoSuchSession
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.NonNormalizedNumber
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.RateLimited
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.RegistrationLocked
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.Success
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.TokenNotAccepted
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.UnknownError
import org.thoughtcrime.securesms.registration.ui.toE164
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.kbs.MasterKey
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration.Companion.minutes
/**
* ViewModel shared across all of registration.
*/
class RegistrationViewModel : ViewModel() {
private val store = MutableStateFlow(RegistrationState())
private val password = Util.getSecret(18)
private val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception ->
Log.w(TAG, "CoroutineExceptionHandler invoked.", exception)
store.update {
it.copy(
networkError = exception,
inProgress = false
)
}
}
val state: StateFlow<RegistrationState> = store
val uiState = store.asLiveData()
val checkpoint = store.map { it.registrationCheckpoint }.asLiveData()
val lockedTimeRemaining = store.map { it.lockedTimeRemaining }.asLiveData()
val incorrectCodeAttempts = store.map { it.incorrectCodeAttempts }.asLiveData()
val svrTriesRemaining: Int
get() = store.value.svrTriesRemaining
var isReregister: Boolean
get() = store.value.isReRegister
set(value) {
store.update {
it.copy(isReRegister = value)
}
}
val phoneNumber: Phonenumber.PhoneNumber?
get() = store.value.phoneNumber
fun maybePrefillE164(context: Context) {
Log.v(TAG, "maybePrefillE164()")
if (Permissions.hasAll(context, Manifest.permission.READ_PHONE_STATE, Manifest.permission.READ_PHONE_NUMBERS)) {
val localNumber = Util.getDeviceNumber(context).getOrNull()
if (localNumber != null) {
Log.v(TAG, "Phone number detected.")
setPhoneNumber(localNumber)
} else {
Log.i(TAG, "Could not read phone number.")
}
} else {
Log.i(TAG, "No phone permission.")
}
}
fun setInProgress(inProgress: Boolean) {
store.update {
it.copy(inProgress = inProgress)
}
}
fun setRegistrationCheckpoint(checkpoint: RegistrationCheckpoint) {
store.update {
it.copy(registrationCheckpoint = checkpoint)
}
}
fun setPhoneNumber(phoneNumber: Phonenumber.PhoneNumber?) {
store.update {
it.copy(
phoneNumber = phoneNumber,
sessionId = null
)
}
}
fun setCaptchaResponse(token: String) {
store.update {
it.copy(
registrationCheckpoint = RegistrationCheckpoint.CHALLENGE_COMPLETED,
captchaToken = token
)
}
}
fun sessionCreationErrorShown() {
store.update {
it.copy(sessionCreationError = null)
}
}
fun sessionStateErrorShown() {
store.update {
it.copy(sessionStateError = null)
}
}
fun registerAccountErrorShown() {
store.update {
it.copy(registerAccountError = null)
}
}
fun incrementIncorrectCodeAttempts() {
store.update {
it.copy(incorrectCodeAttempts = it.incorrectCodeAttempts + 1)
}
}
fun addPresentedChallenge(challenge: Challenge) {
store.update {
it.copy(challengesPresented = it.challengesPresented.plus(challenge))
}
}
fun removePresentedChallenge(challenge: Challenge) {
store.update {
it.copy(challengesPresented = it.challengesPresented.minus(challenge))
}
}
fun fetchFcmToken(context: Context) {
viewModelScope.launch(context = coroutineExceptionHandler) {
val fcmToken = RegistrationRepository.getFcmToken(context)
store.update {
it.copy(registrationCheckpoint = RegistrationCheckpoint.PUSH_NETWORK_AUDITED, isFcmSupported = true, fcmToken = fcmToken)
}
}
}
private suspend fun updateFcmToken(context: Context): String? {
Log.d(TAG, "Fetching FCM token…")
val fcmToken = RegistrationRepository.getFcmToken(context)
store.update {
it.copy(fcmToken = fcmToken)
}
Log.d(TAG, "FCM token fetched.")
return fcmToken
}
fun onBackupSuccessfullyRestored() {
val recoveryPassword = SignalStore.svr.recoveryPassword
store.update {
it.copy(registrationCheckpoint = RegistrationCheckpoint.BACKUP_RESTORED_OR_SKIPPED, recoveryPassword = SignalStore.svr.recoveryPassword, canSkipSms = recoveryPassword != null, isReRegister = true)
}
}
fun onUserConfirmedPhoneNumber(context: Context) {
setRegistrationCheckpoint(RegistrationCheckpoint.PHONE_NUMBER_CONFIRMED)
val state = store.value
val e164 = state.phoneNumber?.toE164() ?: return bail { Log.i(TAG, "Phone number was null after confirmation.") }
if (!state.userSkippedReregistration) {
if (hasRecoveryPassword() && matchesSavedE164(e164)) {
// Re-registration when the local database is intact.
Log.d(TAG, "Has recovery password, and therefore can skip SMS verification.")
store.update {
it.copy(
canSkipSms = true,
isReRegister = true,
inProgress = false
)
}
return
}
}
viewModelScope.launch {
if (!state.userSkippedReregistration) {
val svrCredentialsResult: BackupAuthCheckResult = RegistrationRepository.hasValidSvrAuthCredentials(context, e164, password)
when (svrCredentialsResult) {
is BackupAuthCheckResult.UnknownError -> {
handleGenericError(svrCredentialsResult.getCause())
return@launch
}
is BackupAuthCheckResult.SuccessWithCredentials -> {
Log.d(TAG, "Found local valid SVR auth credentials.")
store.update {
it.copy(
isReRegister = true,
canSkipSms = true,
svr2AuthCredentials = svrCredentialsResult.svr2Credentials,
svr3AuthCredentials = svrCredentialsResult.svr3Credentials,
inProgress = false
)
}
return@launch
}
is BackupAuthCheckResult.SuccessWithoutCredentials -> {
Log.d(TAG, "No local SVR auth credentials could be found and/or validated.")
}
}
}
val validSession = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for confirming the entered E164.") }
if (validSession.body.verified) {
Log.i(TAG, "Session is already verified, registering account.")
registerVerifiedSession(context, validSession.body.id)
return@launch
}
if (!validSession.body.allowedToRequestCode) {
if (System.currentTimeMillis() > (validSession.body.nextVerificationAttempt ?: Int.MAX_VALUE)) {
store.update {
it.copy(registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED)
}
} else {
val challenges = validSession.body.requestedInformation
Log.i(TAG, "Not allowed to request code! Remaining challenges: ${challenges.joinToString()}")
handleSessionStateResult(context, ChallengeRequired(Challenge.parse(validSession.body.requestedInformation)))
}
return@launch
}
requestSmsCodeInternal(context, validSession.body.id, e164)
}
}
fun requestSmsCode(context: Context) {
val e164 = getCurrentE164() ?: return bail { Log.i(TAG, "Phone number was null after confirmation.") }
viewModelScope.launch {
val validSession = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for requesting an SMS code.") }
requestSmsCodeInternal(context, validSession.body.id, e164)
}
}
fun requestVerificationCall(context: Context) {
val e164 = getCurrentE164()
if (e164 == null) {
Log.w(TAG, "Phone number was null after confirmation.")
onErrorOccurred()
return
}
viewModelScope.launch {
val validSession = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for requesting a verification call.") }
Log.d(TAG, "Requesting voice call code…")
val codeRequestResponse = RegistrationRepository.requestSmsCode(
context = context,
sessionId = validSession.body.id,
e164 = e164,
password = password,
mode = RegistrationRepository.E164VerificationMode.PHONE_CALL
)
Log.d(TAG, "Voice code request network call completed.")
handleSessionStateResult(context, codeRequestResponse)
if (codeRequestResponse is Success) {
Log.d(TAG, "Voice code request was successful.")
}
}
}
private suspend fun requestSmsCodeInternal(context: Context, sessionId: String, e164: String) {
var smsListenerReady = false
Log.d(TAG, "Initializing SMS listener.")
if (store.value.smsListenerTimeout < System.currentTimeMillis()) {
smsListenerReady = store.value.isFcmSupported && RegistrationRepository.registerSmsListener(context)
if (smsListenerReady) {
val smsRetrieverTimeout = System.currentTimeMillis() + 5.minutes.inWholeMilliseconds
Log.d(TAG, "Successfully started verification code SMS retriever, which will last until $smsRetrieverTimeout.")
store.update { it.copy(smsListenerTimeout = smsRetrieverTimeout) }
} else {
Log.d(TAG, "Could not start verification code SMS retriever.")
}
}
Log.d(TAG, "Requesting SMS code…")
val transportMode = if (smsListenerReady) RegistrationRepository.E164VerificationMode.SMS_WITH_LISTENER else RegistrationRepository.E164VerificationMode.SMS_WITHOUT_LISTENER
val codeRequestResponse = RegistrationRepository.requestSmsCode(
context = context,
sessionId = sessionId,
e164 = e164,
password = password,
mode = transportMode
)
Log.d(TAG, "SMS code request network call completed.")
if (codeRequestResponse is AlreadyVerified) {
Log.d(TAG, "Got session was already verified when requesting SMS code.")
registerVerifiedSession(context, sessionId)
return
}
handleSessionStateResult(context, codeRequestResponse)
if (codeRequestResponse is Success) {
Log.d(TAG, "SMS code request was successful.")
store.update {
it.copy(
registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED
)
}
}
}
private suspend fun getOrCreateValidSession(context: Context): RegistrationSessionMetadataResponse? {
Log.v(TAG, "getOrCreateValidSession()")
val e164 = getCurrentE164() ?: throw IllegalStateException("E164 required to create session!")
val mccMncProducer = MccMncProducer(context)
val existingSessionId = store.value.sessionId
return getOrCreateValidSession(
context = context,
existingSessionId = existingSessionId,
e164 = e164,
password = password,
mcc = mccMncProducer.mcc,
mnc = mccMncProducer.mnc,
successListener = { networkResult ->
store.update {
it.copy(
sessionId = networkResult.body.id,
nextSmsTimestamp = RegistrationRepository.deriveTimestamp(networkResult.headers, networkResult.body.nextSms),
nextCallTimestamp = RegistrationRepository.deriveTimestamp(networkResult.headers, networkResult.body.nextCall),
nextVerificationAttempt = RegistrationRepository.deriveTimestamp(networkResult.headers, networkResult.body.nextVerificationAttempt),
allowedToRequestCode = networkResult.body.allowedToRequestCode,
challengesRequested = Challenge.parse(networkResult.body.requestedInformation),
verified = networkResult.body.verified,
inProgress = false
)
}
},
errorHandler = { error ->
Log.d(TAG, "Setting ${error::class.simpleName} as session creation error.")
store.update {
it.copy(
sessionCreationError = error,
inProgress = false
)
}
}
)
}
fun submitCaptchaToken(context: Context) {
val e164 = getCurrentE164() ?: throw IllegalStateException("Can't submit captcha token if no phone number is set!")
val captchaToken = store.value.captchaToken ?: throw IllegalStateException("Can't submit captcha token if no captcha token is set!")
store.update {
it.copy(captchaToken = null)
}
viewModelScope.launch {
val session = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for submitting a captcha token.") }
Log.d(TAG, "Submitting captcha token…")
val captchaSubmissionResult = RegistrationRepository.submitCaptchaToken(context, e164, password, session.body.id, captchaToken)
Log.d(TAG, "Captcha token submitted.")
handleSessionStateResult(context, captchaSubmissionResult)
}
}
fun requestAndSubmitPushToken(context: Context) {
Log.v(TAG, "validatePushToken()")
addPresentedChallenge(Challenge.PUSH)
val e164 = getCurrentE164() ?: throw IllegalStateException("Can't submit captcha token if no phone number is set!")
viewModelScope.launch {
Log.d(TAG, "Getting session in order to perform push token verification…")
val session = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for submitting a push challenge token.") }
if (!Challenge.parse(session.body.requestedInformation).contains(Challenge.PUSH)) {
Log.d(TAG, "Push submission no longer necessary, bailing.")
store.update {
it.copy(
inProgress = false
)
}
return@launch bail { Log.i(TAG, "Push challenge token no longer needed, bailing.") }
}
Log.d(TAG, "Requesting push challenge token…")
val pushSubmissionResult = RegistrationRepository.requestAndVerifyPushToken(context, session.body.id, e164, password)
Log.d(TAG, "Push challenge token submitted.")
handleSessionStateResult(context, pushSubmissionResult)
}
}
/**
* @return whether the request was successful and execution should continue
*/
private suspend fun handleSessionStateResult(context: Context, sessionResult: VerificationCodeRequestResult): Boolean {
Log.v(TAG, "handleSessionStateResult()")
when (sessionResult) {
is UnknownError -> {
handleGenericError(sessionResult.getCause())
}
is Success -> {
Log.d(TAG, "New registration session status received.")
updateFcmToken(context)
store.update {
it.copy(
sessionId = sessionResult.sessionId,
nextSmsTimestamp = sessionResult.nextSmsTimestamp,
nextCallTimestamp = sessionResult.nextCallTimestamp,
isAllowedToRequestCode = sessionResult.allowedToRequestCode,
challengesRequested = emptyList(),
inProgress = false
)
}
return true
}
is ChallengeRequired -> {
Log.d(TAG, "[${sessionResult.challenges.joinToString()}] registration challenges received.")
store.update {
it.copy(
registrationCheckpoint = RegistrationCheckpoint.CHALLENGE_RECEIVED,
challengesRequested = sessionResult.challenges,
inProgress = false
)
}
return false
}
is AttemptsExhausted -> Log.i(TAG, "Received AttemptsExhausted.", sessionResult.getCause())
is ImpossibleNumber -> Log.i(TAG, "Received ImpossibleNumber.", sessionResult.getCause())
is NonNormalizedNumber -> Log.i(TAG, "Received NonNormalizedNumber.", sessionResult.getCause())
is RateLimited -> Log.i(TAG, "Received RateLimited.", sessionResult.getCause())
is ExternalServiceFailure -> Log.i(TAG, "Received ExternalServiceFailure.", sessionResult.getCause())
is InvalidTransportModeFailure -> Log.i(TAG, "Received InvalidTransportModeFailure.", sessionResult.getCause())
is MalformedRequest -> Log.i(TAG, "Received MalformedRequest.", sessionResult.getCause())
is MustRetry -> Log.i(TAG, "Received MustRetry.", sessionResult.getCause())
is TokenNotAccepted -> Log.i(TAG, "Received TokenNotAccepted.", sessionResult.getCause())
is RegistrationLocked -> {
store.update {
it.copy(lockedTimeRemaining = sessionResult.timeRemaining)
}
Log.i(TAG, "Received RegistrationLocked.", sessionResult.getCause())
}
is NoSuchSession -> Log.i(TAG, "Received NoSuchSession.", sessionResult.getCause())
is AlreadyVerified -> Log.i(TAG, "Received AlreadyVerified", sessionResult.getCause())
}
setInProgress(false)
store.update {
it.copy(
sessionStateError = sessionResult
)
}
return false
}
/**
* @return whether the request was successful and execution should continue
*/
private suspend fun handleRegistrationResult(context: Context, registrationData: RegistrationData, registrationResult: RegisterAccountResult, reglockEnabled: Boolean): Boolean {
Log.v(TAG, "handleRegistrationResult()")
when (registrationResult) {
is RegisterAccountResult.Success -> {
Log.i(TAG, "Register account result: Success! Registration lock: $reglockEnabled")
store.update {
it.copy(
registrationCheckpoint = RegistrationCheckpoint.SERVICE_REGISTRATION_COMPLETED
)
}
onSuccessfulRegistration(context, registrationData, registrationResult.accountRegistrationResult, reglockEnabled)
return true
}
is RegisterAccountResult.IncorrectRecoveryPassword -> {
Log.i(TAG, "Registration recovery password was incorrect, falling back to SMS verification.", registrationResult.getCause())
setUserSkippedReRegisterFlow(true)
}
is RegisterAccountResult.RegistrationLocked -> {
Log.i(TAG, "Account is registration locked!", registrationResult.getCause())
}
is RegisterAccountResult.SvrWrongPin -> {
Log.i(TAG, "Received wrong SVR PIN response! ${registrationResult.triesRemaining} tries remaining.")
updateSvrTriesRemaining(registrationResult.triesRemaining)
}
is RegisterAccountResult.SvrNoData,
is RegisterAccountResult.AttemptsExhausted,
is RegisterAccountResult.RateLimited,
is RegisterAccountResult.AuthorizationFailed,
is RegisterAccountResult.MalformedRequest,
is RegisterAccountResult.ValidationError,
is RegisterAccountResult.UnknownError -> Log.i(TAG, "Received error when trying to register!", registrationResult.getCause())
}
setInProgress(false)
store.update {
it.copy(
registerAccountError = registrationResult
)
}
return false
}
private fun handleGenericError(cause: Throwable) {
Log.w(TAG, "Encountered unknown error!", cause)
store.update {
it.copy(inProgress = false, networkError = cause)
}
}
private fun setRecoveryPassword(recoveryPassword: String?) {
store.update {
it.copy(recoveryPassword = recoveryPassword)
}
}
private fun updateSvrTriesRemaining(remainingTries: Int) {
store.update {
it.copy(svrTriesRemaining = remainingTries)
}
}
fun setUserSkippedReRegisterFlow(value: Boolean) {
store.update {
it.copy(userSkippedReregistration = value, canSkipSms = !value)
}
}
fun verifyReRegisterWithPin(context: Context, pin: String, wrongPinHandler: () -> Unit) {
setInProgress(true)
// Local recovery password
if (RegistrationRepository.canUseLocalRecoveryPassword()) {
if (RegistrationRepository.doesPinMatchLocalHash(pin)) {
Log.d(TAG, "Found recovery password, attempting to re-register.")
viewModelScope.launch(context = coroutineExceptionHandler) {
val masterKey = SignalStore.svr.masterKey
setRecoveryPassword(masterKey.deriveRegistrationRecoveryPassword())
verifyReRegisterInternal(context, pin, masterKey)
setInProgress(false)
}
} else {
Log.d(TAG, "Entered PIN did not match local PIN hash.")
wrongPinHandler()
setInProgress(false)
}
return
}
// remote recovery password
val svr2Credentials = store.value.svr2AuthCredentials
val svr3Credentials = store.value.svr3AuthCredentials
if (svr2Credentials != null || svr3Credentials != null) {
Log.d(TAG, "Found SVR auth credentials, fetching recovery password from SVR (svr2: ${svr2Credentials != null}, svr3: ${svr3Credentials != null}).")
viewModelScope.launch(context = coroutineExceptionHandler) {
try {
val masterKey = RegistrationRepository.fetchMasterKeyFromSvrRemote(pin, svr2Credentials, svr3Credentials)
setRecoveryPassword(masterKey.deriveRegistrationRecoveryPassword())
updateSvrTriesRemaining(10)
verifyReRegisterInternal(context, pin, masterKey)
} catch (rejectedPin: SvrWrongPinException) {
Log.w(TAG, "Submitted PIN was rejected by SVR.", rejectedPin)
updateSvrTriesRemaining(rejectedPin.triesRemaining)
wrongPinHandler()
} catch (noData: SvrNoDataException) {
Log.w(TAG, "SVR has no data for these credentials. Aborting skip SMS flow.", noData)
updateSvrTriesRemaining(0)
setUserSkippedReRegisterFlow(true)
}
setInProgress(false)
}
return
}
Log.w(TAG, "Could not get credentials to skip SMS registration, aborting!")
store.update {
it.copy(canSkipSms = false, inProgress = false)
}
}
private suspend fun verifyReRegisterInternal(context: Context, pin: String?, masterKey: MasterKey) {
Log.v(TAG, "verifyReRegisterInternal(hasPin=${pin != null})")
updateFcmToken(context)
val registrationData = getRegistrationData()
val resultAndRegLockStatus = registerAccountInternal(context, null, registrationData, pin, masterKey)
val result = resultAndRegLockStatus.first
val reglockEnabled = resultAndRegLockStatus.second
handleRegistrationResult(context, registrationData, result, reglockEnabled)
}
/**
* @return a [Pair] containing the server response and a boolean signifying whether the current account is registration locked.
*/
private suspend fun registerAccountInternal(context: Context, sessionId: String?, registrationData: RegistrationData, pin: String?, masterKey: MasterKey): Pair<RegisterAccountResult, Boolean> {
Log.v(TAG, "registerAccountInternal()")
var registrationResult: RegisterAccountResult = RegistrationRepository.registerAccount(context = context, sessionId = sessionId, registrationData = registrationData, pin = pin)
// Check if reg lock is enabled
if (registrationResult !is RegisterAccountResult.RegistrationLocked) {
if (registrationResult is RegisterAccountResult.Success) {
registrationResult = RegisterAccountResult.Success(registrationResult.accountRegistrationResult.copy(masterKey = masterKey))
}
Log.i(TAG, "Received a non-registration lock response to registration. Assuming registration lock as DISABLED")
return Pair(registrationResult, false)
}
Log.i(TAG, "Received a registration lock response when trying to register an account. Retrying with master key.")
store.update {
it.copy(
svr2AuthCredentials = registrationResult.svr2Credentials,
svr3AuthCredentials = registrationResult.svr3Credentials
)
}
return Pair(RegistrationRepository.registerAccount(context = context, sessionId = sessionId, registrationData = registrationData, pin = pin) { masterKey }, true)
}
fun verifyCodeWithoutRegistrationLock(context: Context, code: String) {
Log.v(TAG, "verifyCodeWithoutRegistrationLock()")
store.update {
it.copy(
inProgress = true,
enteredCode = code,
registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_ENTERED
)
}
viewModelScope.launch(context = coroutineExceptionHandler) {
verifyCodeInternal(
context = context,
registrationLocked = false,
pin = null
)
}
}
fun verifyCodeAndRegisterAccountWithRegistrationLock(context: Context, pin: String) {
Log.v(TAG, "verifyCodeAndRegisterAccountWithRegistrationLock()")
store.update {
it.copy(
inProgress = true,
registrationCheckpoint = RegistrationCheckpoint.PIN_ENTERED
)
}
viewModelScope.launch {
verifyCodeInternal(
context = context,
registrationLocked = true,
pin = pin
)
}
}
private suspend fun verifyCodeInternal(context: Context, registrationLocked: Boolean, pin: String?) {
Log.d(TAG, "Getting valid session in order to submit verification code.")
if (registrationLocked && pin.isNullOrBlank()) {
throw IllegalStateException("Must have PIN to register with registration lock!")
}
var reglock = registrationLocked
val sessionId = getOrCreateValidSession(context)?.body?.id ?: return
val registrationData = getRegistrationData()
Log.d(TAG, "Submitting verification code…")
val verificationResponse = RegistrationRepository.submitVerificationCode(context, sessionId, registrationData)
val submissionSuccessful = verificationResponse is Success
val alreadyVerified = verificationResponse is AlreadyVerified
Log.d(TAG, "Verification code submission network call completed. Submission successful? $submissionSuccessful Account already verified? $alreadyVerified")
if (!submissionSuccessful && !alreadyVerified) {
handleSessionStateResult(context, verificationResponse)
return
}
Log.d(TAG, "Submitting registration…")
var result: RegisterAccountResult? = null
var state = store.value
if (!reglock) {
Log.d(TAG, "Registration lock not enabled, attempting to register account without master key producer.")
result = RegistrationRepository.registerAccount(context, sessionId, registrationData, pin)
}
if (result is RegisterAccountResult.RegistrationLocked) {
Log.d(TAG, "Registration lock response received.")
val timeRemaining = result.timeRemaining
store.update {
it.copy(lockedTimeRemaining = timeRemaining)
}
reglock = true
if (pin == null && SignalStore.svr.registrationLockToken != null) {
Log.d(TAG, "Retrying registration with stored credentials.")
result = RegistrationRepository.registerAccount(context, sessionId, registrationData, SignalStore.svr.pin) { SignalStore.svr.masterKey }
} else if (result.svr2Credentials != null || result.svr3Credentials != null) {
Log.d(TAG, "Retrying registration with received credentials (svr2: ${result.svr2Credentials != null}, svr3: ${result.svr3Credentials != null}).")
val svr2Credentials = result.svr2Credentials
val svr3Credentials = result.svr3Credentials
state = store.updateAndGet {
it.copy(svr2AuthCredentials = svr2Credentials, svr3AuthCredentials = svr3Credentials)
}
}
}
if (reglock && pin.isNotNullOrBlank()) {
Log.d(TAG, "Registration lock enabled, attempting to register account restore master key from SVR (svr2: ${state.svr2AuthCredentials != null}, svr3: ${state.svr3AuthCredentials != null})")
result = RegistrationRepository.registerAccount(context, sessionId, registrationData, pin) {
SvrRepository.restoreMasterKeyPreRegistration(
credentials = SvrAuthCredentialSet(
svr2Credentials = state.svr2AuthCredentials,
svr3Credentials = state.svr3AuthCredentials
),
userPin = pin
)
}
}
if (result != null) {
handleRegistrationResult(context, registrationData, result, reglock)
} else {
Log.w(TAG, "No registration response received!")
}
}
private suspend fun registerVerifiedSession(context: Context, sessionId: String) {
Log.v(TAG, "registerVerifiedSession()")
val registrationData = getRegistrationData()
val registrationResponse: RegisterAccountResult = RegistrationRepository.registerAccount(context, sessionId, registrationData)
handleRegistrationResult(context, registrationData, registrationResponse, false)
}
private suspend fun onSuccessfulRegistration(context: Context, registrationData: RegistrationData, remoteResult: AccountRegistrationResult, reglockEnabled: Boolean) = withContext(Dispatchers.IO) {
Log.v(TAG, "onSuccessfulRegistration()")
val metadata = LocalRegistrationMetadataUtil.createLocalRegistrationMetadata(SignalStore.account.aciIdentityKey, SignalStore.account.pniIdentityKey, registrationData, remoteResult, reglockEnabled)
SignalStore.registration.localRegistrationMetadata = metadata
RegistrationRepository.registerAccountLocally(context, metadata)
if (!remoteResult.storageCapable && !SignalStore.registration.hasCompletedRestore()) {
// Not being storage capable is a high signal that account is new and there's no data to restore
SignalStore.registration.markSkippedTransferOrRestore()
}
if (reglockEnabled || SignalStore.svr.hasOptedInWithAccess()) {
SignalStore.onboarding.clearAll()
}
if (reglockEnabled || SignalStore.svr.hasOptedInWithAccess()) {
val stopwatch = Stopwatch("post-reg-storage-service")
AppDependencies.jobManager.runSynchronously(StorageAccountRestoreJob(), StorageAccountRestoreJob.LIFESPAN)
stopwatch.split("account-restore")
AppDependencies.jobManager
.startChain(StorageSyncJob())
.then(ReclaimUsernameAndLinkJob())
.enqueueAndBlockUntilCompletion(TimeUnit.SECONDS.toMillis(10))
stopwatch.split("storage-sync")
BackupRepository.restoreBackupTier(SignalStore.account.requireAci())
stopwatch.split("backup-tier")
stopwatch.stop(TAG)
}
refreshRemoteConfig()
store.update {
it.copy(
registrationCheckpoint = RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE,
inProgress = false
)
}
}
fun completeRegistration() {
AppDependencies.jobManager.startChain(ProfileUploadJob()).then(listOf(MultiDeviceProfileKeyUpdateJob(), MultiDeviceProfileContentUpdateJob())).enqueue()
RegistrationUtil.maybeMarkRegistrationComplete()
}
fun networkErrorShown() {
store.update {
it.copy(networkError = null)
}
}
private fun matchesSavedE164(e164: String?): Boolean {
return if (e164 == null) {
false
} else {
e164 == SignalStore.account.e164
}
}
private fun hasRecoveryPassword(): Boolean {
return store.value.recoveryPassword != null
}
private fun getCurrentE164(): String? {
return store.value.phoneNumber?.toE164()
}
private suspend fun getRegistrationData(): RegistrationData {
val currentState = store.value
val code = currentState.enteredCode
val e164: String = currentState.phoneNumber?.toE164() ?: throw IllegalStateException("Can't construct registration data without E164!")
val recoveryPassword = if (currentState.sessionId == null && hasRecoveryPassword()) store.value.recoveryPassword!! else null
return RegistrationData(code, e164, password, RegistrationRepository.getRegistrationId(), RegistrationRepository.getProfileKey(e164), currentState.fcmToken, RegistrationRepository.getPniRegistrationId(), recoveryPassword)
}
/**
* This is a generic error UI handler that re-enables the UI so that the user can recover from errors.
* Do not forget to log any errors when calling this method!
*/
private fun onErrorOccurred() {
setInProgress(false)
}
/**
* Used for early returns in order to end the in-progress visual state, as well as print a log message explaining what happened.
*
* @param logMessage Logging code is wrapped in lambda so that our automated tools detect the various [Log] calls with their accompanying messages.
*/
private fun bail(logMessage: () -> Unit) {
logMessage()
setInProgress(false)
}
fun registerWithBackupKey(context: Context, backupKey: String, e164: String?, pin: String?) {
setInProgress(true)
viewModelScope.launch(context = coroutineExceptionHandler) {
if (e164 != null) {
setPhoneNumber(PhoneNumberUtil.getInstance().parse(e164, null))
}
// TODO [backups] use new data and not master key
val masterKey = MasterKey(Hex.fromStringCondensed(backupKey))
SignalStore.svr.setMasterKey(masterKey, pin)
setRecoveryPassword(masterKey.deriveRegistrationRecoveryPassword())
verifyReRegisterInternal(context = context, pin = pin, masterKey = masterKey)
setInProgress(false)
}
}
companion object {
private val TAG = Log.tag(RegistrationViewModel::class.java)
private suspend fun refreshRemoteConfig() = withContext(Dispatchers.IO) {
val startTime = System.currentTimeMillis()
try {
RemoteConfig.refreshSync()
Log.i(TAG, "Took " + (System.currentTimeMillis() - startTime) + " ms to get feature flags.")
} catch (e: IOException) {
Log.w(TAG, "Failed to refresh flags after " + (System.currentTimeMillis() - startTime) + " ms.", e)
}
}
suspend fun getOrCreateValidSession(
context: Context,
existingSessionId: String?,
e164: String,
password: String,
mcc: String?,
mnc: String?,
successListener: (RegistrationSessionMetadataResponse) -> Unit,
errorHandler: (RegistrationSessionResult) -> Unit
): RegistrationSessionMetadataResponse? {
Log.d(TAG, "Validating/creating a registration session.")
val sessionResult: RegistrationSessionResult = RegistrationRepository.createOrValidateSession(context, existingSessionId, e164, password, mcc, mnc)
when (sessionResult) {
is RegistrationSessionCheckResult.Success -> {
val metadata = sessionResult.getMetadata()
successListener(metadata)
Log.d(TAG, "Registration session validated.")
return metadata
}
is RegistrationSessionCreationResult.Success -> {
val metadata = sessionResult.getMetadata()
successListener(metadata)
Log.d(TAG, "Registration session created.")
return metadata
}
else -> {
Log.d(TAG, "Handling error during session creation.")
errorHandler(sessionResult)
}
}
return null
}
}
}

View file

@ -0,0 +1,67 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.accountlocked
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.activityViewModels
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
import kotlin.time.Duration.Companion.milliseconds
/**
* Screen educating the user that they need to wait some number of days to register.
*/
class AccountLockedFragment : LoggingFragment(R.layout.account_locked_fragment) {
private val viewModel by activityViewModels<RegistrationViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setDebugLogSubmitMultiTapView(view.findViewById(R.id.account_locked_title))
val description = view.findViewById<TextView>(R.id.account_locked_description)
viewModel.lockedTimeRemaining.observe(
viewLifecycleOwner
) { t: Long? -> description.text = getString(R.string.AccountLockedFragment__your_account_has_been_locked_to_protect_your_privacy, durationToDays(t!!)) }
view.findViewById<View>(R.id.account_locked_next).setOnClickListener { v: View? -> onNext() }
view.findViewById<View>(R.id.account_locked_learn_more).setOnClickListener { v: View? -> learnMore() }
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
onNext()
}
}
)
}
private fun learnMore() {
val intent = Intent(Intent.ACTION_VIEW)
intent.setData(Uri.parse(getString(R.string.AccountLockedFragment__learn_more_url)))
startActivity(intent)
}
fun onNext() {
requireActivity().finish()
}
private fun durationToDays(duration: Long): Long {
return if (duration != 0L) getLockoutDays(duration).toLong() else 7
}
private fun getLockoutDays(timeRemainingMs: Long): Int {
return timeRemainingMs.milliseconds.inWholeDays.toInt() + 1
}
}

View file

@ -0,0 +1,61 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.captcha
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.View
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.OnBackPressedCallback
import androidx.activity.addCallback
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentRegistrationCaptchaBinding
import org.thoughtcrime.securesms.registration.fragments.RegistrationConstants
abstract class CaptchaFragment : LoggingFragment(R.layout.fragment_registration_captcha) {
private val binding: FragmentRegistrationCaptchaBinding by ViewBinderDelegate(FragmentRegistrationCaptchaBinding::bind)
private val backListener = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
handleUserExit()
}
}
@SuppressLint("SetJavaScriptEnabled")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.registrationCaptchaWebView.settings.javaScriptEnabled = true
binding.registrationCaptchaWebView.clearCache(true)
binding.registrationCaptchaWebView.webViewClient = object : WebViewClient() {
@Deprecated("Deprecated in Java")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
if (url.startsWith(RegistrationConstants.SIGNAL_CAPTCHA_SCHEME)) {
val token = url.substring(RegistrationConstants.SIGNAL_CAPTCHA_SCHEME.length)
handleCaptchaToken(token)
backListener.isEnabled = false
findNavController().navigateUp()
return true
}
return false
}
}
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) {
handleUserExit()
}
binding.registrationCaptchaWebView.loadUrl(BuildConfig.SIGNAL_CAPTCHA_URL)
}
abstract fun handleCaptchaToken(token: String)
abstract fun handleUserExit()
}

View file

@ -0,0 +1,34 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.captcha
import android.os.Bundle
import android.view.View
import androidx.fragment.app.activityViewModels
import org.thoughtcrime.securesms.registration.data.network.Challenge
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
/**
* Screen that displays a captcha as part of the registration flow.
* This subclass plugs in [RegistrationViewModel] to the shared super class.
*
* @see CaptchaFragment
*/
class RegistrationCaptchaFragment : CaptchaFragment() {
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
sharedViewModel.addPresentedChallenge(Challenge.CAPTCHA)
}
override fun handleCaptchaToken(token: String) {
sharedViewModel.setCaptchaResponse(token)
}
override fun handleUserExit() {
sharedViewModel.removePresentedChallenge(Challenge.CAPTCHA)
}
}

View file

@ -0,0 +1,300 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.entercode
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.i18n.phonenumbers.PhoneNumberUtil
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
import org.thoughtcrime.securesms.databinding.FragmentRegistrationEnterCodeBinding
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.fragments.ContactSupportBottomSheetFragment
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
import org.thoughtcrime.securesms.registration.fragments.SignalStrengthPhoneStateListener
import org.thoughtcrime.securesms.registration.sms.ReceivedSmsEvent
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.visible
/**
* The final screen of account registration, where the user enters their verification code.
*/
class EnterCodeFragment : LoggingFragment(R.layout.fragment_registration_enter_code) {
companion object {
private const val BOTTOM_SHEET_TAG = "support_bottom_sheet"
}
private val TAG = Log.tag(EnterCodeFragment::class.java)
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val fragmentViewModel by viewModels<EnterCodeViewModel>()
private val bottomSheet = ContactSupportBottomSheetFragment()
private val binding: FragmentRegistrationEnterCodeBinding by ViewBinderDelegate(FragmentRegistrationEnterCodeBinding::bind)
private lateinit var phoneStateListener: SignalStrengthPhoneStateListener
private var autopilotCodeEntryActive = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setDebugLogSubmitMultiTapView(binding.verifyHeader)
phoneStateListener = SignalStrengthPhoneStateListener(this, PhoneStateCallback())
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
popBackStack()
}
}
)
binding.wrongNumber.setOnClickListener {
popBackStack()
}
binding.code.setOnCompleteListener {
sharedViewModel.verifyCodeWithoutRegistrationLock(requireContext(), it)
}
binding.havingTroubleButton.setOnClickListener {
bottomSheet.showSafely(childFragmentManager, BOTTOM_SHEET_TAG)
}
binding.callMeCountDown.apply {
setTextResources(R.string.RegistrationActivity_call, R.string.RegistrationActivity_call_me_instead_available_in)
setOnClickListener {
sharedViewModel.requestVerificationCall(requireContext())
}
}
binding.resendSmsCountDown.apply {
setTextResources(R.string.RegistrationActivity_resend_code, R.string.RegistrationActivity_resend_sms_available_in)
setOnClickListener {
sharedViewModel.requestSmsCode(requireContext())
}
}
binding.keyboard.setOnKeyPressListener { key ->
if (!autopilotCodeEntryActive) {
if (key >= 0) {
binding.code.append(key)
} else {
binding.code.delete()
}
}
}
sharedViewModel.incorrectCodeAttempts.observe(viewLifecycleOwner) { attempts: Int ->
if (attempts >= 3) {
binding.havingTroubleButton.visible = true
}
}
sharedViewModel.uiState.observe(viewLifecycleOwner) {
it.sessionStateError?.let { error ->
handleSessionErrorResponse(error)
sharedViewModel.sessionStateErrorShown()
}
it.registerAccountError?.let { error ->
handleRegistrationErrorResponse(error)
sharedViewModel.registerAccountErrorShown()
}
binding.resendSmsCountDown.startCountDownTo(it.nextSmsTimestamp)
binding.callMeCountDown.startCountDownTo(it.nextCallTimestamp)
if (it.inProgress) {
binding.keyboard.displayProgress()
} else {
binding.keyboard.displayKeyboard()
}
}
fragmentViewModel.uiState.observe(viewLifecycleOwner) {
if (it.resetRequiredAfterFailure) {
binding.callMeCountDown.visibility = View.VISIBLE
binding.resendSmsCountDown.visibility = View.VISIBLE
binding.wrongNumber.visibility = View.VISIBLE
binding.code.clear()
binding.keyboard.displayKeyboard()
fragmentViewModel.allViewsResetCompleted()
} else if (it.showKeyboard) {
binding.keyboard.displayKeyboard()
fragmentViewModel.keyboardShown()
}
}
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = viewLifecycleOwner)
}
override fun onResume() {
super.onResume()
sharedViewModel.phoneNumber?.let {
val formatted = PhoneNumberUtil.getInstance().format(it, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL)
binding.verificationSubheader.text = requireContext().getString(R.string.RegistrationActivity_enter_the_code_we_sent_to_s, formatted)
}
}
private fun handleSessionErrorResponse(result: VerificationCodeRequestResult) {
when (result) {
is VerificationCodeRequestResult.Success -> throw IllegalStateException("Session error handler called on successful response!")
is VerificationCodeRequestResult.RateLimited -> presentRateLimitedDialog()
is VerificationCodeRequestResult.AttemptsExhausted -> presentAccountLocked()
is VerificationCodeRequestResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
else -> presentGenericError(result)
}
}
private fun handleRegistrationErrorResponse(result: RegisterAccountResult) {
when (result) {
is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!")
is RegisterAccountResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
is RegisterAccountResult.AuthorizationFailed -> presentIncorrectCodeDialog()
is RegisterAccountResult.AttemptsExhausted -> presentAccountLocked()
is RegisterAccountResult.RateLimited -> presentRateLimitedDialog()
else -> presentGenericError(result)
}
}
private fun presentAccountLocked() {
binding.keyboard.displayLocked().addListener(
object : AssertedSuccessListener<Boolean>() {
override fun onSuccess(result: Boolean?) {
findNavController().safeNavigate(EnterCodeFragmentDirections.actionAccountLocked())
}
}
)
}
private fun presentRegistrationLocked(timeRemaining: Long) {
binding.keyboard.displayLocked().addListener(
object : AssertedSuccessListener<Boolean>() {
override fun onSuccess(result: Boolean?) {
findNavController().safeNavigate(EnterCodeFragmentDirections.actionRequireKbsLockPin(timeRemaining))
}
}
)
}
private fun presentRateLimitedDialog() {
binding.keyboard.displayFailure().addListener(
object : AssertedSuccessListener<Boolean?>() {
override fun onSuccess(result: Boolean?) {
MaterialAlertDialogBuilder(requireContext()).apply {
setTitle(R.string.RegistrationActivity_too_many_attempts)
setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
fragmentViewModel.resetAllViews()
}
show()
}
}
}
)
}
private fun presentIncorrectCodeDialog() {
sharedViewModel.incrementIncorrectCodeAttempts()
Toast.makeText(requireContext(), R.string.RegistrationActivity_incorrect_code, Toast.LENGTH_LONG).show()
binding.keyboard.displayFailure().addListener(object : AssertedSuccessListener<Boolean?>() {
override fun onSuccess(result: Boolean?) {
fragmentViewModel.resetAllViews()
}
})
}
private fun presentGenericError(requestResult: RegistrationResult) {
binding.keyboard.displayFailure().addListener(
object : AssertedSuccessListener<Boolean>() {
override fun onSuccess(result: Boolean?) {
Log.w(TAG, "Encountered unexpected error!", requestResult.getCause())
MaterialAlertDialogBuilder(requireContext()).apply {
null?.let<String, MaterialAlertDialogBuilder> {
setTitle(it)
}
setMessage(getString(R.string.RegistrationActivity_error_connecting_to_service))
setPositiveButton(android.R.string.ok) { _, _ -> fragmentViewModel.showKeyboard() }
show()
}
}
}
)
}
private fun popBackStack() {
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PUSH_NETWORK_AUDITED)
NavHostFragment.findNavController(this).popBackStack()
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onVerificationCodeReceived(event: ReceivedSmsEvent) {
Log.i(TAG, "Received verification code via EventBus.")
binding.code.clear()
if (event.code.isBlank() || event.code.length != ReceivedSmsEvent.CODE_LENGTH) {
Log.i(TAG, "Received invalid code of length ${event.code.length}. Ignoring.")
return
}
val finalIndex = ReceivedSmsEvent.CODE_LENGTH - 1
autopilotCodeEntryActive = true
try {
event.code
.map { it.digitToInt() }
.forEachIndexed { i, digit ->
binding.code.postDelayed({
binding.code.append(digit)
if (i == finalIndex) {
autopilotCodeEntryActive = false
}
}, i * 200L)
}
Log.i(TAG, "Finished auto-filling code.")
} catch (notADigit: IllegalArgumentException) {
Log.w(TAG, "Failed to convert code into digits.", notADigit)
autopilotCodeEntryActive = false
}
}
private inner class PhoneStateCallback : SignalStrengthPhoneStateListener.Callback {
override fun onNoCellSignalPresent() {
if (isAdded) {
bottomSheet.showSafely(childFragmentManager, BOTTOM_SHEET_TAG)
}
}
override fun onCellSignalPresent() {
if (bottomSheet.isResumed) {
bottomSheet.dismiss()
}
}
}
}

View file

@ -0,0 +1,8 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.entercode
data class EnterCodeState(val resetRequiredAfterFailure: Boolean = false, val showKeyboard: Boolean = false)

View file

@ -0,0 +1,37 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.entercode
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
class EnterCodeViewModel : ViewModel() {
private val store = MutableStateFlow(EnterCodeState())
val uiState = store.asLiveData()
fun resetAllViews() {
store.update { it.copy(resetRequiredAfterFailure = true) }
}
fun allViewsResetCompleted() {
store.update {
it.copy(
resetRequiredAfterFailure = false,
showKeyboard = false
)
}
}
fun showKeyboard() {
store.update { it.copy(showKeyboard = true) }
}
fun keyboardShown() {
store.update { it.copy(showKeyboard = false) }
}
}

View file

@ -0,0 +1,87 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.permissions
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.registration.fragments.WelcomePermissions
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registrationv3.ui.welcome.WelcomeUserSelection
import org.thoughtcrime.securesms.util.BackupUtil
/**
* Screen in account registration that provides rationales for the suggested runtime permissions.
*/
@RequiresApi(23)
class GrantPermissionsFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(GrantPermissionsFragment::class.java)
const val REQUEST_KEY = "GrantPermissionsFragment"
}
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val args by navArgs<GrantPermissionsFragmentArgs>()
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
::onPermissionsGranted
)
private val welcomeUserSelection: WelcomeUserSelection by lazy { args.welcomeUserSelection }
@Composable
override fun FragmentContent() {
GrantPermissionsScreen(
deviceBuildVersion = Build.VERSION.SDK_INT,
isBackupSelectionRequired = BackupUtil.isUserSelectionRequired(LocalContext.current),
onNextClicked = this::launchPermissionRequests,
onNotNowClicked = this::proceedToNextScreen
)
}
private fun launchPermissionRequests() {
val isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext())
val neededPermissions = WelcomePermissions.getWelcomePermissions(isUserSelectionRequired).filterNot {
ContextCompat.checkSelfPermission(requireContext(), it) == PackageManager.PERMISSION_GRANTED
}
if (neededPermissions.isEmpty()) {
proceedToNextScreen()
} else {
requestPermissionLauncher.launch(neededPermissions.toTypedArray())
}
}
private fun onPermissionsGranted(permissions: Map<String, Boolean>) {
permissions.forEach {
Log.d(TAG, "${it.key} = ${it.value}")
}
sharedViewModel.maybePrefillE164(requireContext())
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED)
proceedToNextScreen()
}
private fun proceedToNextScreen() {
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to welcomeUserSelection))
findNavController().popBackStack()
}
}

View file

@ -0,0 +1,147 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.permissions
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
/**
* Layout that explains permissions rationale to the user.
*/
@Composable
fun GrantPermissionsScreen(
deviceBuildVersion: Int,
isBackupSelectionRequired: Boolean,
onNextClicked: () -> Unit = {},
onNotNowClicked: () -> Unit = {}
) {
RegistrationScreen(
title = stringResource(id = R.string.GrantPermissionsFragment__allow_permissions),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__to_help_you_message_people_you_know),
bottomContent = {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
TextButton(onClick = onNotNowClicked) {
Text(
text = stringResource(id = R.string.GrantPermissionsFragment__not_now)
)
}
Buttons.LargeTonal(
onClick = onNextClicked
) {
Text(
text = stringResource(id = R.string.GrantPermissionsFragment__next)
)
}
}
}
) {
if (deviceBuildVersion >= 33) {
PermissionRow(
imageVector = ImageVector.vectorResource(id = R.drawable.permission_notification),
title = stringResource(id = R.string.GrantPermissionsFragment__notifications),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__get_notified_when)
)
}
PermissionRow(
imageVector = ImageVector.vectorResource(id = R.drawable.permission_contact),
title = stringResource(id = R.string.GrantPermissionsFragment__contacts),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__find_people_you_know)
)
if (deviceBuildVersion < 29 || !isBackupSelectionRequired) {
PermissionRow(
imageVector = ImageVector.vectorResource(id = R.drawable.permission_file),
title = stringResource(id = R.string.GrantPermissionsFragment__storage),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__send_photos_videos_and_files)
)
}
PermissionRow(
imageVector = ImageVector.vectorResource(id = R.drawable.permission_phone),
title = stringResource(id = R.string.GrantPermissionsFragment__phone_calls),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__make_registering_easier)
)
}
}
@SignalPreview
@Composable
fun GrantPermissionsScreenPreview() {
Previews.Preview {
GrantPermissionsScreen(
deviceBuildVersion = 33,
isBackupSelectionRequired = true
)
}
}
@Composable
fun PermissionRow(
imageVector: ImageVector,
title: String,
subtitle: String
) {
Row(modifier = Modifier.padding(bottom = 32.dp)) {
Image(
imageVector = imageVector,
contentDescription = null,
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.size(16.dp))
Column {
Text(
text = title,
style = MaterialTheme.typography.titleSmall
)
Text(
text = subtitle,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.size(32.dp))
}
}
@SignalPreview
@Composable
fun PermissionRowPreview() {
Previews.Preview {
PermissionRow(
imageVector = ImageVector.vectorResource(id = R.drawable.permission_notification),
title = stringResource(id = R.string.GrantPermissionsFragment__notifications),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__get_notified_when)
)
}
}

View file

@ -0,0 +1,654 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.phonenumber
import android.content.Context
import android.content.DialogInterface
import android.os.Bundle
import android.text.Editable
import android.text.SpannableStringBuilder
import android.text.TextWatcher
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.ArrayAdapter
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.MenuProvider
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.MaterialAutoCompleteTextView
import com.google.android.material.textfield.TextInputEditText
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentRegistrationEnterPhoneNumberBinding
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.registration.data.network.Challenge
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCheckResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
import org.thoughtcrime.securesms.registration.ui.toE164
import org.thoughtcrime.securesms.registration.util.CountryPrefix
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationState
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.Dialogs
import org.thoughtcrime.securesms.util.PlayServicesUtil
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.SupportEmailUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.livedata.LiveDataObserverCallback
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.visible
import kotlin.time.Duration.Companion.milliseconds
/**
* Screen in registration where the user enters their phone number.
*/
class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_enter_phone_number) {
private val TAG = Log.tag(EnterPhoneNumberFragment::class.java)
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val fragmentViewModel by viewModels<EnterPhoneNumberViewModel>()
private val args by navArgs<EnterPhoneNumberFragmentArgs>()
private val binding: FragmentRegistrationEnterPhoneNumberBinding by ViewBinderDelegate(FragmentRegistrationEnterPhoneNumberBinding::bind)
private val enterPhoneNumberMode: EnterPhoneNumberMode by lazy { args.enterPhoneNumberMode }
private var processedResumeMode: Boolean = false
private val skipToNextScreen: DialogInterface.OnClickListener = DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> moveToVerificationEntryScreen() }
private lateinit var spinnerAdapter: ArrayAdapter<CountryPrefix>
private lateinit var phoneNumberInputLayout: TextInputEditText
private lateinit var spinnerView: MaterialAutoCompleteTextView
private var currentPhoneNumberFormatter: TextWatcher? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setDebugLogSubmitMultiTapView(binding.verifyHeader)
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
popBackStack()
}
}
)
phoneNumberInputLayout = binding.number.editText as TextInputEditText
spinnerView = binding.countryCode.editText as MaterialAutoCompleteTextView
spinnerAdapter = ArrayAdapter<CountryPrefix>(
requireContext(),
R.layout.registration_country_code_dropdown_item,
fragmentViewModel.supportedCountryPrefixes
)
binding.registerButton.setOnClickListener { onRegistrationButtonClicked() }
binding.toolbar.title = ""
val activity = requireActivity() as AppCompatActivity
activity.setSupportActionBar(binding.toolbar)
requireActivity().addMenuProvider(UseProxyMenuProvider(), viewLifecycleOwner)
sharedViewModel.uiState.observe(viewLifecycleOwner) { sharedState ->
presentRegisterButton(sharedState)
presentProgressBar(sharedState.inProgress, sharedState.isReRegister)
sharedState.networkError?.let {
presentNetworkError(it)
sharedViewModel.networkErrorShown()
}
sharedState.sessionCreationError?.let {
handleSessionCreationError(it)
sharedViewModel.sessionCreationErrorShown()
}
sharedState.sessionStateError?.let {
handleSessionStateError(it)
sharedViewModel.sessionStateErrorShown()
}
sharedState.registerAccountError?.let {
handleRegistrationErrorResponse(it)
sharedViewModel.registerAccountErrorShown()
}
if (sharedState.challengesRequested.contains(Challenge.CAPTCHA) && sharedState.captchaToken.isNotNullOrBlank()) {
sharedViewModel.submitCaptchaToken(requireContext())
} else if (sharedState.challengesRemaining.isNotEmpty()) {
handleChallenges(sharedState.challengesRemaining)
} else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.PHONE_NUMBER_CONFIRMED && sharedState.canSkipSms) {
moveToEnterPinScreen()
} else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED) {
moveToVerificationEntryScreen()
}
}
fragmentViewModel.uiState.observe(viewLifecycleOwner) { fragmentState ->
fragmentState.phoneNumberFormatter?.let {
bindPhoneNumberFormatter(it)
phoneNumberInputLayout.requestFocus()
}
if (fragmentViewModel.isEnteredNumberPossible(fragmentState)) {
sharedViewModel.setPhoneNumber(fragmentViewModel.parsePhoneNumber(fragmentState))
} else {
sharedViewModel.setPhoneNumber(null)
}
if (fragmentState.error != EnterPhoneNumberState.Error.NONE) {
presentLocalError(fragmentState)
}
}
initializeInputFields()
val existingPhoneNumber = sharedViewModel.phoneNumber
if (existingPhoneNumber != null) {
fragmentViewModel.restoreState(existingPhoneNumber)
spinnerView.setText(existingPhoneNumber.countryCode.toString())
fragmentViewModel.formatter?.let {
bindPhoneNumberFormatter(it)
}
phoneNumberInputLayout.setText(existingPhoneNumber.nationalNumber.toString())
} else {
spinnerView.setText(fragmentViewModel.countryPrefix().toString())
}
if (enterPhoneNumberMode == EnterPhoneNumberMode.RESTART_AFTER_COLLECTION && (savedInstanceState == null && !processedResumeMode)) {
processedResumeMode = true
startNormalRegistration()
} else {
ViewUtil.focusAndShowKeyboard(phoneNumberInputLayout)
}
}
private fun bindPhoneNumberFormatter(formatter: TextWatcher) {
if (formatter != currentPhoneNumberFormatter) {
currentPhoneNumberFormatter?.let { oldWatcher ->
Log.d(TAG, "Removing current phone number formatter in fragment")
phoneNumberInputLayout.removeTextChangedListener(oldWatcher)
}
phoneNumberInputLayout.addTextChangedListener(formatter)
currentPhoneNumberFormatter = formatter
Log.d(TAG, "Updated phone number formatter in fragment")
}
}
private fun handleChallenges(remainingChallenges: List<Challenge>) {
when (remainingChallenges.first()) {
Challenge.CAPTCHA -> moveToCaptcha()
Challenge.PUSH -> performPushChallenge()
}
}
private fun performPushChallenge() {
sharedViewModel.requestAndSubmitPushToken(requireContext())
}
private fun initializeInputFields() {
binding.countryCode.editText?.addTextChangedListener { s ->
val sanitized = s.toString().filter { c -> c.isDigit() }
if (sanitized.isNotNullOrBlank()) {
val countryCode: Int = sanitized.toInt()
fragmentViewModel.setCountry(countryCode)
}
}
phoneNumberInputLayout.addTextChangedListener {
fragmentViewModel.setPhoneNumber(it?.toString())
}
val scrollView = binding.scrollView
val registerButton = binding.registerButton
phoneNumberInputLayout.onFocusChangeListener = View.OnFocusChangeListener { _: View?, hasFocus: Boolean ->
if (hasFocus) {
scrollView.postDelayed({
scrollView.smoothScrollTo(0, registerButton.bottom)
}, 250)
}
}
phoneNumberInputLayout.imeOptions = EditorInfo.IME_ACTION_DONE
phoneNumberInputLayout.setOnEditorActionListener { v: TextView?, actionId: Int, _: KeyEvent? ->
if (actionId == EditorInfo.IME_ACTION_DONE && v != null) {
onRegistrationButtonClicked()
return@setOnEditorActionListener true
}
false
}
spinnerView.threshold = 100
spinnerView.setAdapter(spinnerAdapter)
spinnerView.addTextChangedListener(afterTextChanged = ::onCountryDropDownChanged)
}
private fun onCountryDropDownChanged(s: Editable?) {
if (s.isNullOrEmpty()) {
return
}
if (s[0] != '+') {
s.insert(0, "+")
}
fragmentViewModel.supportedCountryPrefixes.firstOrNull { it.toString() == s.toString() }?.let {
fragmentViewModel.setCountry(it.digits)
val numberLength: Int = phoneNumberInputLayout.text?.length ?: 0
phoneNumberInputLayout.setSelection(numberLength, numberLength)
}
}
private fun presentRegisterButton(sharedState: RegistrationState) {
binding.registerButton.isEnabled = sharedState.phoneNumber != null && PhoneNumberUtil.getInstance().isPossibleNumber(sharedState.phoneNumber)
if (sharedState.inProgress) {
binding.registerButton.setSpinning()
} else {
binding.registerButton.cancelSpinning()
}
}
private fun presentLocalError(state: EnterPhoneNumberState) {
when (state.error) {
EnterPhoneNumberState.Error.NONE -> Unit
EnterPhoneNumberState.Error.INVALID_PHONE_NUMBER -> {
MaterialAlertDialogBuilder(requireContext()).apply {
setTitle(R.string.RegistrationActivity_invalid_number)
setMessage(
String.format(
getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid),
state.phoneNumber
)
)
setPositiveButton(android.R.string.ok) { _, _ -> fragmentViewModel.clearError() }
setOnCancelListener { fragmentViewModel.clearError() }
setOnDismissListener { fragmentViewModel.clearError() }
show()
}
}
EnterPhoneNumberState.Error.PLAY_SERVICES_MISSING -> {
handlePromptForNoPlayServices()
}
EnterPhoneNumberState.Error.PLAY_SERVICES_NEEDS_UPDATE -> {
GoogleApiAvailability.getInstance().getErrorDialog(requireActivity(), ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED, 0)?.show()
}
EnterPhoneNumberState.Error.PLAY_SERVICES_TRANSIENT -> {
MaterialAlertDialogBuilder(requireContext()).apply {
setTitle(R.string.RegistrationActivity_play_services_error)
setMessage(R.string.RegistrationActivity_google_play_services_is_updating_or_unavailable)
setPositiveButton(android.R.string.ok) { _, _ -> fragmentViewModel.clearError() }
setOnCancelListener { fragmentViewModel.clearError() }
setOnDismissListener { fragmentViewModel.clearError() }
show()
}
}
}
}
private fun presentNetworkError(networkError: Throwable) {
Log.i(TAG, "Unknown error during verification code request", networkError)
MaterialAlertDialogBuilder(requireContext()).apply {
setMessage(R.string.RegistrationActivity_unable_to_connect_to_service)
setPositiveButton(android.R.string.ok, null)
show()
}
}
private fun handleSessionCreationError(result: RegistrationSessionResult) {
if (!result.isSuccess()) {
Log.i(TAG, "Handling error response of ${result.javaClass.name}", result.getCause())
}
when (result) {
is RegistrationSessionCheckResult.Success,
is RegistrationSessionCreationResult.Success -> throw IllegalStateException("Session error handler called on successful response!")
is RegistrationSessionCreationResult.AttemptsExhausted -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_service))
is RegistrationSessionCreationResult.MalformedRequest -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen)
is RegistrationSessionCreationResult.RateLimited -> {
Log.i(TAG, "Session creation rate limited! Next attempt: ${result.timeRemaining.milliseconds}")
presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_try_again, result.timeRemaining.milliseconds.toString()))
}
is RegistrationSessionCreationResult.ServerUnableToParse -> presentGenericError(result)
is RegistrationSessionCheckResult.SessionNotFound -> presentGenericError(result)
is RegistrationSessionCheckResult.UnknownError,
is RegistrationSessionCreationResult.UnknownError -> presentGenericError(result)
}
}
private fun handleSessionStateError(result: VerificationCodeRequestResult) {
if (!result.isSuccess()) {
Log.i(TAG, "Handling error response.", result.getCause())
}
when (result) {
is VerificationCodeRequestResult.Success -> throw IllegalStateException("Session error handler called on successful response!")
is VerificationCodeRequestResult.AttemptsExhausted -> presentRateLimitedDialog()
is VerificationCodeRequestResult.ChallengeRequired -> handleChallenges(result.challenges)
is VerificationCodeRequestResult.ExternalServiceFailure -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen)
is VerificationCodeRequestResult.ImpossibleNumber -> {
MaterialAlertDialogBuilder(requireContext()).apply {
setMessage(getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid, fragmentViewModel.phoneNumber?.toE164()))
setPositiveButton(android.R.string.ok, null)
show()
}
}
is VerificationCodeRequestResult.InvalidTransportModeFailure -> {
MaterialAlertDialogBuilder(requireContext()).apply {
setMessage(R.string.RegistrationActivity_we_couldnt_send_you_a_verification_code)
setPositiveButton(R.string.RegistrationActivity_voice_call) { _, _ ->
sharedViewModel.requestVerificationCall(requireContext())
}
setNegativeButton(R.string.RegistrationActivity_cancel, null)
show()
}
}
is VerificationCodeRequestResult.MalformedRequest -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen)
is VerificationCodeRequestResult.MustRetry -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen)
is VerificationCodeRequestResult.NonNormalizedNumber -> handleNonNormalizedNumberError(result.originalNumber, result.normalizedNumber, fragmentViewModel.e164VerificationMode)
is VerificationCodeRequestResult.RateLimited -> {
Log.i(TAG, "Code request rate limited! Next attempt: ${result.timeRemaining.milliseconds}")
presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_try_again, result.timeRemaining.milliseconds.toString()))
}
is VerificationCodeRequestResult.TokenNotAccepted -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_we_need_to_verify_that_youre_human)) { _, _ -> moveToCaptcha() }
is VerificationCodeRequestResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
is VerificationCodeRequestResult.AlreadyVerified -> presentGenericError(result)
is VerificationCodeRequestResult.NoSuchSession -> presentGenericError(result)
is VerificationCodeRequestResult.UnknownError -> presentGenericError(result)
}
}
private fun presentGenericError(result: RegistrationResult) {
Log.i(TAG, "Received unhandled response: ${result.javaClass.name}", result.getCause())
presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service))
}
private fun handleRegistrationErrorResponse(result: RegisterAccountResult) {
when (result) {
is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!")
is RegisterAccountResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
is RegisterAccountResult.AttemptsExhausted -> presentAccountLocked()
is RegisterAccountResult.RateLimited -> presentRateLimitedDialog()
is RegisterAccountResult.SvrNoData -> presentAccountLocked()
else -> presentGenericError(result)
}
}
private fun presentRegistrationLocked(timeRemaining: Long) {
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionPhoneNumberRegistrationLock(timeRemaining))
}
private fun presentRateLimitedDialog() {
presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_service))
}
private fun presentAccountLocked() {
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionPhoneNumberAccountLocked())
}
private fun moveToCaptcha() {
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionRequestCaptcha())
}
private fun presentRemoteErrorDialog(message: String, positiveButtonListener: DialogInterface.OnClickListener? = null) {
MaterialAlertDialogBuilder(requireContext()).apply {
setMessage(message)
setPositiveButton(android.R.string.ok, positiveButtonListener)
show()
}
}
private fun handleNonNormalizedNumberError(originalNumber: String, normalizedNumber: String, mode: RegistrationRepository.E164VerificationMode) {
try {
val phoneNumber = PhoneNumberUtil.getInstance().parse(normalizedNumber, null)
MaterialAlertDialogBuilder(requireContext()).apply {
setTitle(R.string.RegistrationActivity_non_standard_number_format)
setMessage(getString(R.string.RegistrationActivity_the_number_you_entered_appears_to_be_a_non_standard, originalNumber, normalizedNumber))
setNegativeButton(android.R.string.no) { d: DialogInterface, i: Int -> d.dismiss() }
setNeutralButton(R.string.RegistrationActivity_contact_signal_support) { dialogInterface, _ ->
val subject = getString(R.string.RegistrationActivity_signal_android_phone_number_format)
val body = SupportEmailUtil.generateSupportEmailBody(requireContext(), R.string.RegistrationActivity_signal_android_phone_number_format, null, null)
CommunicationActions.openEmail(requireContext(), SupportEmailUtil.getSupportEmailAddress(requireContext()), subject, body)
dialogInterface.dismiss()
}
setPositiveButton(R.string.yes) { dialogInterface, _ ->
spinnerView.setText(phoneNumber.countryCode.toString())
phoneNumberInputLayout.setText(phoneNumber.nationalNumber.toString())
when (mode) {
RegistrationRepository.E164VerificationMode.SMS_WITH_LISTENER,
RegistrationRepository.E164VerificationMode.SMS_WITHOUT_LISTENER -> sharedViewModel.requestSmsCode(requireContext())
RegistrationRepository.E164VerificationMode.PHONE_CALL -> sharedViewModel.requestVerificationCall(requireContext())
}
dialogInterface.dismiss()
}
show()
}
} catch (e: NumberParseException) {
Log.w(TAG, "Failed to parse number!", e)
Dialogs.showAlertDialog(
requireContext(),
getString(R.string.RegistrationActivity_invalid_number),
getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid, fragmentViewModel.phoneNumber?.toE164())
)
}
}
private fun onRegistrationButtonClicked() {
when (enterPhoneNumberMode) {
EnterPhoneNumberMode.NORMAL,
EnterPhoneNumberMode.RESTART_AFTER_COLLECTION -> startNormalRegistration()
EnterPhoneNumberMode.COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE -> findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.goToEnterBackupKey())
}
}
private fun startNormalRegistration() {
ViewUtil.hideKeyboard(requireContext(), phoneNumberInputLayout)
sharedViewModel.setInProgress(true)
val hasFcm = validateFcmStatus(requireContext())
if (hasFcm) {
sharedViewModel.uiState.observe(viewLifecycleOwner, FcmTokenRetrievedObserver())
sharedViewModel.fetchFcmToken(requireContext())
} else {
sharedViewModel.uiState.value?.let { value ->
val now = System.currentTimeMillis()
if (value.phoneNumber == null) {
fragmentViewModel.setError(EnterPhoneNumberState.Error.INVALID_PHONE_NUMBER)
sharedViewModel.setInProgress(false)
} else if (now < value.nextSmsTimestamp) {
moveToVerificationEntryScreen()
} else {
presentConfirmNumberDialog(value.phoneNumber, value.isReRegister, value.canSkipSms, missingFcmConsentRequired = true)
}
}
}
}
private fun onFcmTokenRetrieved(value: RegistrationState) {
if (value.phoneNumber == null) {
fragmentViewModel.setError(EnterPhoneNumberState.Error.INVALID_PHONE_NUMBER)
sharedViewModel.setInProgress(false)
} else {
presentConfirmNumberDialog(value.phoneNumber, value.isReRegister, value.canSkipSms, missingFcmConsentRequired = false)
}
}
private fun presentProgressBar(showProgress: Boolean, isReRegister: Boolean) {
if (showProgress) {
binding.registerButton.setSpinning()
} else {
binding.registerButton.cancelSpinning()
}
binding.countryCode.isEnabled = !showProgress
binding.number.isEnabled = !showProgress
binding.cancelButton.visible = !showProgress && isReRegister
}
private fun validateFcmStatus(context: Context): Boolean {
val fcmStatus = PlayServicesUtil.getPlayServicesStatus(context)
Log.d(TAG, "Got $fcmStatus for Play Services status.")
when (fcmStatus) {
PlayServicesUtil.PlayServicesStatus.SUCCESS -> {
return true
}
PlayServicesUtil.PlayServicesStatus.MISSING -> {
fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_MISSING)
return false
}
PlayServicesUtil.PlayServicesStatus.NEEDS_UPDATE -> {
fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_NEEDS_UPDATE)
return false
}
PlayServicesUtil.PlayServicesStatus.TRANSIENT_ERROR -> {
fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_TRANSIENT)
return false
}
null -> {
Log.w(TAG, "Null result received from PlayServicesUtil, marking Play Services as missing.")
fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_MISSING)
return false
}
}
}
private fun handleConfirmNumberDialogCanceled() {
Log.d(TAG, "User canceled confirm number, returning to edit number.")
sharedViewModel.setInProgress(false)
ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(phoneNumberInputLayout)
}
private fun presentConfirmNumberDialog(phoneNumber: PhoneNumber, isReRegister: Boolean, canSkipSms: Boolean, missingFcmConsentRequired: Boolean) {
val title = if (isReRegister) {
R.string.RegistrationActivity_additional_verification_required
} else {
R.string.RegistrationActivity_phone_number_verification_dialog_title
}
val message: CharSequence = SpannableStringBuilder().apply {
append(SpanUtil.bold(PhoneNumberFormatter.prettyPrint(phoneNumber.toE164())))
if (!canSkipSms) {
append("\n\n")
append(getString(R.string.RegistrationActivity_a_verification_code_will_be_sent_to_this_number))
}
}
MaterialAlertDialogBuilder(requireContext()).apply {
setTitle(title)
setMessage(message)
setPositiveButton(android.R.string.ok) { _, _ ->
Log.d(TAG, "User confirmed number.")
if (missingFcmConsentRequired) {
handlePromptForNoPlayServices()
} else {
sharedViewModel.onUserConfirmedPhoneNumber(requireContext())
}
}
setNegativeButton(R.string.RegistrationActivity_edit_number) { _, _ -> handleConfirmNumberDialogCanceled() }
setOnCancelListener { _ -> handleConfirmNumberDialogCanceled() }
}.show()
}
private fun handlePromptForNoPlayServices() {
val context = activity
if (context != null) {
Log.d(TAG, "Device does not have Play Services, showing consent dialog.")
MaterialAlertDialogBuilder(context).apply {
setTitle(R.string.RegistrationActivity_missing_google_play_services)
setMessage(R.string.RegistrationActivity_this_device_is_missing_google_play_services)
setPositiveButton(R.string.RegistrationActivity_i_understand) { _, _ ->
Log.d(TAG, "User confirmed number.")
sharedViewModel.onUserConfirmedPhoneNumber(AppDependencies.application)
}
setNegativeButton(android.R.string.cancel, null)
setOnCancelListener { fragmentViewModel.clearError() }
setOnDismissListener { fragmentViewModel.clearError() }
show()
}
}
}
private fun moveToEnterPinScreen() {
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionReRegisterWithPinFragment())
sharedViewModel.setInProgress(false)
}
private fun moveToVerificationEntryScreen() {
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionEnterVerificationCode())
sharedViewModel.setInProgress(false)
}
private fun popBackStack() {
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.INITIALIZATION)
findNavController().popBackStack()
}
private inner class FcmTokenRetrievedObserver : LiveDataObserverCallback<RegistrationState>(sharedViewModel.uiState) {
override fun onValue(value: RegistrationState): Boolean {
val fcmRetrieved = value.isFcmSupported
if (fcmRetrieved) {
onFcmTokenRetrieved(value)
}
return fcmRetrieved
}
}
private inner class UseProxyMenuProvider : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.enter_phone_number, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return if (menuItem.itemId == R.id.phone_menu_use_proxy) {
NavHostFragment.findNavController(this@EnterPhoneNumberFragment).safeNavigate(EnterPhoneNumberFragmentDirections.actionEditProxy())
true
} else {
false
}
}
}
}

View file

@ -0,0 +1,20 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.phonenumber
/**
* Enter phone number mode to determine if verification is needed or just e164 input is necessary.
*/
enum class EnterPhoneNumberMode {
/** Normal registration start, collect number to verify */
NORMAL,
/** User pre-selected restore/transfer flow, collect number to re-register and restore with */
COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE,
/** User reversed decision on restore and needs to resume normal re-register but automatically start verify */
RESTART_AFTER_COLLECTION
}

View file

@ -0,0 +1,24 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.phonenumber
import android.text.TextWatcher
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
/**
* State holder for the phone number entry screen, including phone number and Play Services errors.
*/
data class EnterPhoneNumberState(
val countryPrefixIndex: Int = 0,
val phoneNumber: String = "",
val phoneNumberFormatter: TextWatcher? = null,
val mode: RegistrationRepository.E164VerificationMode = RegistrationRepository.E164VerificationMode.SMS_WITHOUT_LISTENER,
val error: Error = Error.NONE
) {
enum class Error {
NONE, INVALID_PHONE_NUMBER, PLAY_SERVICES_MISSING, PLAY_SERVICES_NEEDS_UPDATE, PLAY_SERVICES_TRANSIENT
}
}

View file

@ -0,0 +1,125 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.phonenumber
import android.telephony.PhoneNumberFormattingTextWatcher
import android.text.TextWatcher
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.registration.util.CountryPrefix
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
/**
* ViewModel for the phone number entry screen.
*/
class EnterPhoneNumberViewModel : ViewModel() {
private val TAG = Log.tag(EnterPhoneNumberViewModel::class.java)
private val store = MutableStateFlow(EnterPhoneNumberState())
val uiState = store.asLiveData()
val formatter: TextWatcher?
get() = store.value.phoneNumberFormatter
val phoneNumber: PhoneNumber?
get() = try {
parsePhoneNumber(store.value)
} catch (ex: NumberParseException) {
Log.w(TAG, "Could not parse phone number in current state.", ex)
null
}
val supportedCountryPrefixes: List<CountryPrefix> = PhoneNumberUtil.getInstance().supportedCallingCodes
.map { CountryPrefix(it, PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(it)) }
.sortedBy { it.digits }
var e164VerificationMode: RegistrationRepository.E164VerificationMode
get() = store.value.mode
set(value) = store.update {
it.copy(mode = value)
}
fun countryPrefix(): CountryPrefix {
return supportedCountryPrefixes[store.value.countryPrefixIndex]
}
fun setPhoneNumber(phoneNumber: String?) {
store.update { it.copy(phoneNumber = phoneNumber ?: "") }
}
fun setCountry(digits: Int) {
val matchingIndex = countryCodeToAdapterIndex(digits)
if (matchingIndex == -1) {
Log.d(TAG, "Invalid country code specified $digits")
return
}
store.update {
it.copy(countryPrefixIndex = matchingIndex)
}
viewModelScope.launch {
withContext(Dispatchers.Default) {
val regionCode = PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(digits)
val textWatcher = PhoneNumberFormattingTextWatcher(regionCode)
store.update {
Log.d(TAG, "Updating phone number formatter in state")
it.copy(phoneNumberFormatter = textWatcher)
}
}
}
}
fun parsePhoneNumber(state: EnterPhoneNumberState): PhoneNumber {
return PhoneNumberUtil.getInstance().parse(state.phoneNumber, supportedCountryPrefixes[state.countryPrefixIndex].regionCode)
}
fun isEnteredNumberPossible(state: EnterPhoneNumberState): Boolean {
return try {
PhoneNumberUtil.getInstance().isPossibleNumber(parsePhoneNumber(state))
} catch (ex: NumberParseException) {
false
}
}
fun restoreState(value: PhoneNumber) {
val prefixIndex = countryCodeToAdapterIndex(value.countryCode)
if (prefixIndex != -1) {
store.update {
it.copy(
countryPrefixIndex = prefixIndex,
phoneNumber = value.nationalNumber.toString()
)
}
}
}
private fun countryCodeToAdapterIndex(countryCode: Int): Int {
return supportedCountryPrefixes.indexOfFirst { prefix -> prefix.digits == countryCode }
}
fun clearError() {
setError(EnterPhoneNumberState.Error.NONE)
}
fun setError(error: EnterPhoneNumberState.Error) {
store.update {
it.copy(error = error)
}
}
}

View file

@ -0,0 +1,304 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.registrationlock
import android.os.Bundle
import android.text.InputType
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentRegistrationLockBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.lock.v2.SvrConstants
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SupportEmailUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.util.concurrent.TimeUnit
class RegistrationLockFragment : LoggingFragment(R.layout.fragment_registration_lock) {
companion object {
private val TAG = Log.tag(RegistrationLockFragment::class.java)
}
private val binding: FragmentRegistrationLockBinding by ViewBinderDelegate(FragmentRegistrationLockBinding::bind)
private val viewModel by activityViewModels<RegistrationViewModel>()
private var timeRemaining: Long = 0
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setDebugLogSubmitMultiTapView(view.findViewById(R.id.kbs_lock_pin_title))
val args: RegistrationLockFragmentArgs = RegistrationLockFragmentArgs.fromBundle(requireArguments())
timeRemaining = args.getTimeRemaining()
binding.kbsLockForgotPin.visibility = View.GONE
binding.kbsLockForgotPin.setOnClickListener { handleForgottenPin(timeRemaining) }
binding.kbsLockPinInput.setImeOptions(EditorInfo.IME_ACTION_DONE)
binding.kbsLockPinInput.setOnEditorActionListener { v: TextView?, actionId: Int, _: KeyEvent? ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
ViewUtil.hideKeyboard(requireContext(), v!!)
handlePinEntry()
return@setOnEditorActionListener true
}
false
}
enableAndFocusPinEntry()
binding.kbsLockPinConfirm.setOnClickListener {
ViewUtil.hideKeyboard(requireContext(), binding.kbsLockPinInput)
handlePinEntry()
}
binding.kbsLockKeyboardToggle.setOnClickListener {
val keyboardType: PinKeyboardType = getPinEntryKeyboardType()
updateKeyboard(keyboardType.other)
binding.kbsLockKeyboardToggle.setIconResource(keyboardType.iconResource)
}
val keyboardType: PinKeyboardType = getPinEntryKeyboardType().getOther()
binding.kbsLockKeyboardToggle.setIconResource(keyboardType.iconResource)
viewModel.lockedTimeRemaining.observe(viewLifecycleOwner) { t: Long -> timeRemaining = t }
val triesRemaining: Int = viewModel.svrTriesRemaining
if (triesRemaining <= 3) {
val daysRemaining = getLockoutDays(timeRemaining)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.RegistrationLockFragment__not_many_tries_left)
.setMessage(getTriesRemainingDialogMessage(triesRemaining, daysRemaining))
.setPositiveButton(android.R.string.ok, null)
.setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ -> sendEmailToSupport() }
.show()
}
if (triesRemaining < 5) {
binding.kbsLockPinInputLabel.text = requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__d_attempts_remaining, triesRemaining, triesRemaining)
}
viewModel.uiState.observe(viewLifecycleOwner) {
if (it.inProgress) {
binding.kbsLockPinConfirm.setSpinning()
} else {
binding.kbsLockPinConfirm.cancelSpinning()
}
it.sessionStateError?.let { error ->
handleSessionErrorResponse(error)
viewModel.sessionStateErrorShown()
}
it.registerAccountError?.let { error ->
handleRegistrationErrorResponse(error)
viewModel.registerAccountErrorShown()
}
}
}
private fun handlePinEntry() {
binding.kbsLockPinInput.setEnabled(false)
val pin: String = binding.kbsLockPinInput.getText().toString()
val trimmedLength = pin.replace(" ", "").length
if (trimmedLength == 0) {
Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show()
enableAndFocusPinEntry()
return
}
if (trimmedLength < SvrConstants.MINIMUM_PIN_LENGTH) {
Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, SvrConstants.MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show()
enableAndFocusPinEntry()
return
}
SignalStore.pin.keyboardType = getPinEntryKeyboardType()
binding.kbsLockPinConfirm.setSpinning()
viewModel.verifyCodeAndRegisterAccountWithRegistrationLock(requireContext(), pin)
}
private fun handleSessionErrorResponse(requestResult: VerificationCodeRequestResult) {
when (requestResult) {
is VerificationCodeRequestResult.Success -> throw IllegalStateException("Session error handler called on successful response!")
is VerificationCodeRequestResult.RateLimited -> onRateLimited()
is VerificationCodeRequestResult.AttemptsExhausted -> {
findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
}
is VerificationCodeRequestResult.RegistrationLocked -> {
Log.i(TAG, "Registration locked response to verify account!")
binding.kbsLockPinConfirm.cancelSpinning()
enableAndFocusPinEntry()
Toast.makeText(requireContext(), "Reg lock!", Toast.LENGTH_LONG).show()
}
else -> {
Log.w(TAG, "Unable to verify code with registration lock", requestResult.getCause())
onError()
}
}
}
private fun handleRegistrationErrorResponse(result: RegisterAccountResult) {
when (result) {
is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!")
is RegisterAccountResult.RateLimited -> onRateLimited()
is RegisterAccountResult.AttemptsExhausted -> {
findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
}
is RegisterAccountResult.RegistrationLocked -> {
Log.i(TAG, "Registration locked response to register account!")
binding.kbsLockPinConfirm.cancelSpinning()
enableAndFocusPinEntry()
Toast.makeText(requireContext(), "Reg lock!", Toast.LENGTH_LONG).show()
}
is RegisterAccountResult.SvrWrongPin -> onIncorrectKbsRegistrationLockPin(result.triesRemaining)
is RegisterAccountResult.SvrNoData -> {
findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
}
else -> {
Log.w(TAG, "Unable to register account with registration lock", result.getCause())
onError()
}
}
}
private fun onIncorrectKbsRegistrationLockPin(svrTriesRemaining: Int) {
binding.kbsLockPinConfirm.cancelSpinning()
binding.kbsLockPinInput.getText().clear()
enableAndFocusPinEntry()
if (svrTriesRemaining == 0) {
Log.w(TAG, "Account locked. User out of attempts on KBS.")
findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
return
}
if (svrTriesRemaining == 3) {
val daysRemaining = getLockoutDays(timeRemaining)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.RegistrationLockFragment__incorrect_pin)
.setMessage(getTriesRemainingDialogMessage(svrTriesRemaining, daysRemaining))
.setPositiveButton(android.R.string.ok, null)
.show()
}
if (svrTriesRemaining > 5) {
binding.kbsLockPinInputLabel.setText(R.string.RegistrationLockFragment__incorrect_pin_try_again)
} else {
binding.kbsLockPinInputLabel.text = requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__incorrect_pin_d_attempts_remaining, svrTriesRemaining, svrTriesRemaining)
binding.kbsLockForgotPin.visibility = View.VISIBLE
}
}
private fun onRateLimited() {
binding.kbsLockPinConfirm.cancelSpinning()
enableAndFocusPinEntry()
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.RegistrationActivity_too_many_attempts)
.setMessage(R.string.RegistrationActivity_you_have_made_too_many_incorrect_registration_lock_pin_attempts_please_try_again_in_a_day)
.setPositiveButton(android.R.string.ok, null)
.show()
}
fun onError() {
binding.kbsLockPinConfirm.cancelSpinning()
enableAndFocusPinEntry()
Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show()
}
private fun handleForgottenPin(timeRemainingMs: Long) {
val lockoutDays = getLockoutDays(timeRemainingMs)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.RegistrationLockFragment__forgot_your_pin)
.setMessage(requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__for_your_privacy_and_security_there_is_no_way_to_recover, lockoutDays, lockoutDays))
.setPositiveButton(android.R.string.ok, null)
.setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ -> sendEmailToSupport() }
.show()
}
private fun getLockoutDays(timeRemainingMs: Long): Int {
return TimeUnit.MILLISECONDS.toDays(timeRemainingMs).toInt() + 1
}
private fun getTriesRemainingDialogMessage(triesRemaining: Int, daysRemaining: Int): String {
val resources = requireContext().resources
val tries = resources.getQuantityString(R.plurals.RegistrationLockFragment__you_have_d_attempts_remaining, triesRemaining, triesRemaining)
val days = resources.getQuantityString(R.plurals.RegistrationLockFragment__if_you_run_out_of_attempts_your_account_will_be_locked_for_d_days, daysRemaining, daysRemaining)
return "$tries $days"
}
private fun enableAndFocusPinEntry() {
binding.kbsLockPinInput.setEnabled(true)
binding.kbsLockPinInput.setFocusable(true)
ViewUtil.focusAndShowKeyboard(binding.kbsLockPinInput)
}
private fun getPinEntryKeyboardType(): PinKeyboardType {
val isNumeric = (binding.kbsLockPinInput.inputType and InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_NUMBER
return if (isNumeric) PinKeyboardType.NUMERIC else PinKeyboardType.ALPHA_NUMERIC
}
private fun updateKeyboard(keyboard: PinKeyboardType) {
val isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC
binding.kbsLockPinInput.setInputType(
if (isAlphaNumeric) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
else InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
)
binding.kbsLockPinInput.getText().clear()
}
private fun sendEmailToSupport() {
val subject = R.string.RegistrationLockFragment__signal_registration_need_help_with_pin_for_android_v2_pin
val body = SupportEmailUtil.generateSupportEmailBody(
requireContext(),
subject,
null,
null
)
CommunicationActions.openEmail(
requireContext(),
SupportEmailUtil.getSupportEmailAddress(requireContext()),
getString(subject),
body
)
}
}

View file

@ -0,0 +1,287 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.reregisterwithpin
import android.os.Bundle
import android.text.InputType
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.Toast
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentRegistrationPinRestoreEntryV2Binding
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.lock.v2.SvrConstants
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationState
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SupportEmailUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class ReRegisterWithPinFragment : LoggingFragment(R.layout.fragment_registration_pin_restore_entry_v2) {
companion object {
private val TAG = Log.tag(ReRegisterWithPinFragment::class.java)
}
private val registrationViewModel by activityViewModels<RegistrationViewModel>()
private val reRegisterViewModel by viewModels<ReRegisterWithPinViewModel>()
private val binding: FragmentRegistrationPinRestoreEntryV2Binding by ViewBinderDelegate(FragmentRegistrationPinRestoreEntryV2Binding::bind)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
RegistrationViewDelegate.setDebugLogSubmitMultiTapView(binding.pinRestorePinTitle)
binding.pinRestorePinDescription.setText(R.string.RegistrationLockFragment__enter_the_pin_you_created_for_your_account)
binding.pinRestoreForgotPin.visibility = View.GONE
binding.pinRestoreForgotPin.setOnClickListener { onNeedHelpClicked() }
binding.pinRestoreSkipButton.setOnClickListener { onSkipClicked() }
binding.pinRestorePinInput.imeOptions = EditorInfo.IME_ACTION_DONE
binding.pinRestorePinInput.setOnEditorActionListener { v, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
ViewUtil.hideKeyboard(requireContext(), v!!)
handlePinEntry()
return@setOnEditorActionListener true
}
false
}
enableAndFocusPinEntry()
binding.pinRestorePinContinue.setOnClickListener {
handlePinEntry()
}
binding.pinRestoreKeyboardToggle.setOnClickListener {
val currentKeyboardType: PinKeyboardType = getPinEntryKeyboardType()
updateKeyboard(currentKeyboardType.other)
binding.pinRestoreKeyboardToggle.setIconResource(currentKeyboardType.iconResource)
}
binding.pinRestoreKeyboardToggle.setIconResource(getPinEntryKeyboardType().other.iconResource)
registrationViewModel.uiState.observe(viewLifecycleOwner, ::updateViewState)
}
private fun updateViewState(state: RegistrationState) {
if (state.networkError != null) {
genericErrorDialog()
registrationViewModel.networkErrorShown()
} else if (!state.canSkipSms) {
findNavController().safeNavigate(ReRegisterWithPinFragmentDirections.actionReRegisterWithPinFragmentToEnterPhoneNumberFragment(EnterPhoneNumberMode.NORMAL))
} else if (state.isRegistrationLockEnabled && state.svrTriesRemaining == 0) {
Log.w(TAG, "Unable to continue skip flow, KBS is locked")
onAccountLocked()
} else {
presentProgress(state.inProgress)
presentTriesRemaining(state.svrTriesRemaining)
}
state.registerAccountError?.let { error ->
registrationErrorHandler(error)
registrationViewModel.registerAccountErrorShown()
}
}
private fun presentProgress(inProgress: Boolean) {
if (inProgress) {
ViewUtil.hideKeyboard(requireContext(), binding.pinRestorePinInput)
binding.pinRestorePinInput.isEnabled = false
binding.pinRestorePinContinue.setSpinning()
} else {
binding.pinRestorePinInput.isEnabled = true
binding.pinRestorePinContinue.cancelSpinning()
}
}
private fun handlePinEntry() {
val pin: String? = binding.pinRestorePinInput.text?.toString()
if (pin.isNullOrBlank()) {
Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show()
enableAndFocusPinEntry()
return
}
if (pin.trim().length < SvrConstants.MINIMUM_PIN_LENGTH) {
Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, SvrConstants.MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show()
enableAndFocusPinEntry()
return
}
registrationViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PIN_CONFIRMED)
registrationViewModel.verifyReRegisterWithPin(
context = requireContext(),
pin = pin,
wrongPinHandler = {
reRegisterViewModel.markIncorrectGuess()
}
)
}
private fun presentTriesRemaining(triesRemaining: Int) {
if (reRegisterViewModel.hasIncorrectGuess) {
if (triesRemaining == 1 && !reRegisterViewModel.isLocalVerification) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.PinRestoreEntryFragment_incorrect_pin)
.setMessage(resources.getQuantityString(R.plurals.PinRestoreEntryFragment_you_have_d_attempt_remaining, triesRemaining, triesRemaining))
.setPositiveButton(android.R.string.ok, null)
.show()
}
if (triesRemaining > 5) {
binding.pinRestorePinInputLabel.setText(R.string.PinRestoreEntryFragment_incorrect_pin)
} else {
binding.pinRestorePinInputLabel.text = resources.getQuantityString(R.plurals.RegistrationLockFragment__incorrect_pin_d_attempts_remaining, triesRemaining, triesRemaining)
}
binding.pinRestoreForgotPin.visibility = View.VISIBLE
} else {
if (triesRemaining == 1) {
binding.pinRestoreForgotPin.visibility = View.VISIBLE
if (!reRegisterViewModel.isLocalVerification) {
MaterialAlertDialogBuilder(requireContext())
.setMessage(resources.getQuantityString(R.plurals.PinRestoreEntryFragment_you_have_d_attempt_remaining, triesRemaining, triesRemaining))
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
}
if (triesRemaining == 0) {
Log.w(TAG, "Account locked. User out of attempts on KBS.")
onAccountLocked()
}
}
private fun onAccountLocked() {
Log.d(TAG, "Showing Incorrect PIN dialog. Is local verification: ${reRegisterViewModel.isLocalVerification}")
val message = if (reRegisterViewModel.isLocalVerification) R.string.ReRegisterWithPinFragment_out_of_guesses_local else R.string.PinRestoreLockedFragment_youve_run_out_of_pin_guesses
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.PinRestoreEntryFragment_incorrect_pin)
.setMessage(message)
.setCancelable(false)
.setPositiveButton(R.string.ReRegisterWithPinFragment_send_sms_code) { _, _ -> onSkipPinEntry() }
.setNegativeButton(R.string.AccountLockedFragment__learn_more) { _, _ -> CommunicationActions.openBrowserLink(requireContext(), getString(R.string.PinRestoreLockedFragment_learn_more_url)) }
.show()
}
private fun enableAndFocusPinEntry() {
binding.pinRestorePinInput.isEnabled = true
binding.pinRestorePinInput.isFocusable = true
ViewUtil.focusAndShowKeyboard(binding.pinRestorePinInput)
}
private fun getPinEntryKeyboardType(): PinKeyboardType {
val isNumeric = binding.pinRestorePinInput.inputType and InputType.TYPE_MASK_CLASS == InputType.TYPE_CLASS_NUMBER
return if (isNumeric) PinKeyboardType.NUMERIC else PinKeyboardType.ALPHA_NUMERIC
}
private fun updateKeyboard(keyboard: PinKeyboardType) {
val isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC
binding.pinRestorePinInput.inputType = if (isAlphaNumeric) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD else InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
binding.pinRestorePinInput.text?.clear()
}
private fun onNeedHelpClicked() {
Log.i(TAG, "User clicked need help dialog.")
val message = if (reRegisterViewModel.isLocalVerification) R.string.ReRegisterWithPinFragment_need_help_local else R.string.PinRestoreEntryFragment_your_pin_is_a_d_digit_code
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.PinRestoreEntryFragment_need_help)
.setMessage(getString(message, SvrConstants.MINIMUM_PIN_LENGTH))
.setPositiveButton(R.string.PinRestoreEntryFragment_skip) { _, _ -> onSkipPinEntry() }
.setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ ->
val body = SupportEmailUtil.generateSupportEmailBody(requireContext(), R.string.ReRegisterWithPinFragment_support_email_subject, null, null)
CommunicationActions.openEmail(
requireContext(),
SupportEmailUtil.getSupportEmailAddress(requireContext()),
getString(R.string.ReRegisterWithPinFragment_support_email_subject),
body
)
}
.setNegativeButton(R.string.PinRestoreEntryFragment_cancel, null)
.show()
}
private fun onSkipClicked() {
Log.i(TAG, "User clicked the skip PIN button.")
val message = if (reRegisterViewModel.isLocalVerification) R.string.ReRegisterWithPinFragment_skip_local else R.string.PinRestoreEntryFragment_if_you_cant_remember_your_pin
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.PinRestoreEntryFragment_skip_pin_entry)
.setMessage(message)
.setPositiveButton(R.string.PinRestoreEntryFragment_skip) { _, _ -> onSkipPinEntry() }
.setNegativeButton(R.string.PinRestoreEntryFragment_cancel, null)
.show()
}
private fun onSkipPinEntry() {
Log.d(TAG, "User skipping PIN entry.")
registrationViewModel.setUserSkippedReRegisterFlow(true)
}
private fun presentRateLimitedDialog() {
MaterialAlertDialogBuilder(requireContext()).apply {
setTitle(R.string.RegistrationActivity_too_many_attempts)
setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
setPositiveButton(android.R.string.ok, null)
show()
}
}
private fun genericErrorDialog() {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.RegistrationActivity_error_connecting_to_service)
.setPositiveButton(android.R.string.ok, null)
.create()
.show()
}
private fun registrationErrorHandler(result: RegisterAccountResult) {
when (result) {
is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!")
is RegisterAccountResult.AuthorizationFailed,
is RegisterAccountResult.MalformedRequest,
is RegisterAccountResult.UnknownError,
is RegisterAccountResult.ValidationError,
is RegisterAccountResult.RegistrationLocked -> {
Log.i(TAG, "Registration failed.", result.getCause())
genericErrorDialog()
}
is RegisterAccountResult.IncorrectRecoveryPassword -> {
registrationViewModel.setUserSkippedReRegisterFlow(true)
findNavController().safeNavigate(ReRegisterWithPinFragmentDirections.actionReRegisterWithPinFragmentToEnterPhoneNumberFragment(EnterPhoneNumberMode.NORMAL))
}
is RegisterAccountResult.AttemptsExhausted,
is RegisterAccountResult.RateLimited -> presentRateLimitedDialog()
is RegisterAccountResult.SvrNoData -> onAccountLocked()
is RegisterAccountResult.SvrWrongPin -> {
reRegisterViewModel.markIncorrectGuess()
reRegisterViewModel.markAsRemoteVerification()
}
}
}
}

View file

@ -0,0 +1,12 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.reregisterwithpin
data class ReRegisterWithPinState(
val isLocalVerification: Boolean = false,
val hasIncorrectGuess: Boolean = false,
val localPinMatches: Boolean = false
)

View file

@ -0,0 +1,36 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.reregisterwithpin
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.signal.core.util.logging.Log
class ReRegisterWithPinViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(ReRegisterWithPinViewModel::class.java)
}
private val store = MutableStateFlow(ReRegisterWithPinState())
val isLocalVerification: Boolean
get() = store.value.isLocalVerification
val hasIncorrectGuess: Boolean
get() = store.value.hasIncorrectGuess
fun markAsRemoteVerification() {
store.update {
it.copy(isLocalVerification = false)
}
}
fun markIncorrectGuess() {
store.update {
it.copy(hasIncorrectGuess = true)
}
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.restore
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
/**
* Visual formatter for backup keys.
*
* @param length max length of key
* @param chunkSize character count per group
*/
class BackupKeyVisualTransformation(private val length: Int, private val chunkSize: Int) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
var output = ""
for (i in text.take(length).indices) {
output += text[i]
if (i % chunkSize == chunkSize - 1) {
output += " "
}
}
return TransformedText(
text = AnnotatedString(output),
offsetMapping = BackupKeyVisualTransformation(chunkSize)
)
}
private class BackupKeyVisualTransformation(private val chunkSize: Int) : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
return offset + (offset / chunkSize)
}
override fun transformedToOriginal(offset: Int): Int {
return offset - (offset / chunkSize)
}
}
}

View file

@ -0,0 +1,299 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.restore
import android.graphics.Typeface
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.launch
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationState
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode
import org.thoughtcrime.securesms.registrationv3.ui.restore.EnterBackupKeyViewModel.EnterBackupKeyState
import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Enter backup key screen for manual Signal Backups restore flow.
*/
class EnterBackupKeyFragment : ComposeFragment() {
companion object {
private const val LEARN_MORE_URL = "https://signal.org" // TODO [backups] but really
}
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val viewModel by viewModels<EnterBackupKeyViewModel>()
@Composable
override fun FragmentContent() {
val state by viewModel.state
val sharedState by sharedViewModel.state.collectAsState()
EnterBackupKeyScreen(
state = state,
sharedState = sharedState,
onBackupKeyChanged = viewModel::updateBackupKey,
onNextClicked = {
sharedViewModel.registerWithBackupKey(
context = requireContext(),
backupKey = state.backupKey,
e164 = null,
pin = null
)
},
onLearnMore = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) },
onSkip = { findNavController().safeNavigate(EnterBackupKeyFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION)) }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EnterBackupKeyScreen(
state: EnterBackupKeyState,
sharedState: RegistrationState,
onBackupKeyChanged: (String) -> Unit = {},
onNextClicked: () -> Unit = {},
onLearnMore: () -> Unit = {},
onSkip: () -> Unit = {}
) {
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
RegistrationScreen(
title = stringResource(R.string.EnterBackupKey_title),
subtitle = stringResource(R.string.EnterBackupKey_subtitle),
bottomContent = {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
TextButton(
enabled = !sharedState.inProgress,
onClick = {
coroutineScope.launch {
sheetState.show()
}
}
) {
Text(
text = stringResource(id = R.string.EnterBackupKey_no_backup_key)
)
}
Buttons.LargeTonal(
enabled = state.backupKeyValid && !sharedState.inProgress,
onClick = onNextClicked
) {
Text(
text = stringResource(id = R.string.RegistrationActivity_next)
)
}
}
}
) {
val focusRequester = remember { FocusRequester() }
val visualTransform = remember(state.length, state.chunkLength) { BackupKeyVisualTransformation(length = state.length, chunkSize = state.chunkLength) }
TextField(
value = state.backupKey,
label = {
Text(text = stringResource(id = R.string.EnterBackupKey_backup_key))
},
onValueChange = onBackupKeyChanged,
textStyle = LocalTextStyle.current.copy(
fontFamily = FontFamily(typeface = Typeface.MONOSPACE),
lineHeight = 36.sp
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Next,
autoCorrectEnabled = false
),
keyboardActions = KeyboardActions(
onNext = { if (state.backupKeyValid) onNextClicked() }
),
minLines = 4,
visualTransformation = visualTransform,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
if (sheetState.isVisible) {
ModalBottomSheet(
dragHandle = null,
onDismissRequest = {
coroutineScope.launch {
sheetState.hide()
}
}
) {
NoBackupKeyBottomSheet(
onLearnMore = {
coroutineScope.launch {
sheetState.hide()
}
onLearnMore()
},
onSkip = onSkip
)
}
}
}
}
@SignalPreview
@Composable
private fun EnterBackupKeyScreenPreview() {
Previews.Preview {
EnterBackupKeyScreen(
state = EnterBackupKeyState(backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t", length = 64, chunkLength = 4),
sharedState = RegistrationState(phoneNumber = null, recoveryPassword = null)
)
}
}
@Composable
private fun NoBackupKeyBottomSheet(
onLearnMore: () -> Unit = {},
onSkip: () -> Unit = {}
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
) {
BottomSheets.Handle()
Icon(
painter = painterResource(id = R.drawable.symbol_key_24),
tint = BackupsIconColors.Success.foreground,
contentDescription = null,
modifier = Modifier
.padding(top = 18.dp, bottom = 16.dp)
.size(88.dp)
.background(
color = BackupsIconColors.Success.background,
shape = CircleShape
)
.padding(20.dp)
)
Text(
text = stringResource(R.string.EnterBackupKey_no_backup_key),
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.EnterBackupKey_no_key_paragraph_1),
style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.EnterBackupKey_no_key_paragraph_1),
style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(36.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
TextButton(
onClick = onLearnMore
) {
Text(
text = stringResource(id = R.string.EnterBackupKey_learn_more)
)
}
TextButton(
onClick = onSkip
) {
Text(
text = stringResource(id = R.string.EnterBackupKey_skip_and_dont_restore)
)
}
}
}
}
@SignalPreview
@Composable
private fun NoBackupKeyBottomSheetPreview() {
Previews.BottomSheetPreview {
NoBackupKeyBottomSheet()
}
}

View file

@ -0,0 +1,68 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.restore
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import org.signal.core.util.Hex
import java.io.IOException
class EnterBackupKeyViewModel : ViewModel() {
companion object {
// TODO [backups] Set actual valid characters for key input
private val VALID_CHARACTERS = setOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f')
}
private val _state = mutableStateOf(
EnterBackupKeyState(
backupKey = "",
length = 64,
chunkLength = 4
)
)
val state: State<EnterBackupKeyState> = _state
fun updateBackupKey(key: String) {
_state.update {
val newKey = key.removeIllegalCharacters().take(length)
copy(backupKey = newKey, backupKeyValid = validate(length, newKey))
}
}
private fun validate(length: Int, backupKey: String): Boolean {
if (backupKey.length != length) {
return false
}
try {
// TODO [backups] Actually validate key with requirements instead of just hex
Hex.fromStringCondensed(backupKey)
} catch (e: IOException) {
return false
}
return true
}
private fun String.removeIllegalCharacters(): String {
return filter { VALID_CHARACTERS.contains(it) }
}
private inline fun <T> MutableState<T>.update(update: T.() -> T) {
this.value = this.value.update()
}
data class EnterBackupKeyState(
val backupKey: String = "",
val backupKeyValid: Boolean = false,
val length: Int,
val chunkLength: Int
)
}

View file

@ -0,0 +1,382 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.restore
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.bytes
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeature
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeatureRow
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
import org.thoughtcrime.securesms.util.DateUtils
import java.util.Locale
/**
* Restore backup from remote source.
*/
class RemoteRestoreActivity : BaseActivity() {
companion object {
fun getIntent(context: Context): Intent {
return Intent(context, RemoteRestoreActivity::class.java)
}
}
private val viewModel: RemoteRestoreViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
val restored = viewModel
.state
.map { it.importState }
.filterIsInstance<RemoteRestoreViewModel.ImportState.Restored>()
.firstOrNull()
if (restored != null) {
continueRegistration(restored.missingProfileData)
}
}
setContent {
val state: RemoteRestoreViewModel.ScreenState by viewModel.state.collectAsStateWithLifecycle()
SignalTheme {
Surface {
RestoreFromBackupContent(
state = state,
onRestoreBackupClick = { viewModel.restore() },
onCancelClick = { finish() },
onErrorDialogDismiss = { viewModel.clearError() }
)
}
}
}
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEvent(restoreEvent: RestoreV2Event) {
viewModel.updateRestoreProgress(restoreEvent)
}
private fun continueRegistration(missingProfileData: Boolean) {
val main = MainActivity.clearTop(this)
if (missingProfileData) {
val profile = CreateProfileActivity.getIntentForUserProfile(this)
profile.putExtra("next_intent", main)
startActivity(profile)
} else {
startActivity(main)
}
finish()
}
}
@Composable
private fun RestoreFromBackupContent(
state: RemoteRestoreViewModel.ScreenState,
onRestoreBackupClick: () -> Unit = {},
onCancelClick: () -> Unit = {},
onErrorDialogDismiss: () -> Unit = {}
) {
val subtitle = buildAnnotatedString {
append(
stringResource(
id = R.string.RemoteRestoreActivity__backup_created_at,
DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), state.backupTime),
DateUtils.getOnlyTimeString(LocalContext.current, state.backupTime)
)
)
append(" ")
if (state.backupTier != MessageBackupTier.PAID) {
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
append(stringResource(id = R.string.RemoteRestoreActivity__only_media_sent_or_received))
}
}
}
RegistrationScreen(
title = stringResource(id = R.string.RemoteRestoreActivity__restore_from_backup),
subtitle = if (state.isLoaded()) subtitle else null,
bottomContent = {
Column {
if (state.isLoaded()) {
Buttons.LargeTonal(
onClick = onRestoreBackupClick,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.RemoteRestoreActivity__restore_backup))
}
}
TextButton(
onClick = onCancelClick,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = android.R.string.cancel))
}
}
}
) {
when (state.loadState) {
RemoteRestoreViewModel.ScreenState.LoadState.LOADING -> {
Dialogs.IndeterminateProgressDialog(
message = stringResource(R.string.RemoteRestoreActivity__fetching_backup_details)
)
}
RemoteRestoreViewModel.ScreenState.LoadState.LOADED -> {
Column(
modifier = Modifier
.fillMaxWidth()
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(18.dp))
.padding(horizontal = 20.dp)
.padding(top = 20.dp, bottom = 18.dp)
) {
Text(
text = stringResource(id = R.string.RemoteRestoreActivity__your_backup_includes),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 6.dp)
)
getFeatures(state.backupTier).forEach {
MessageBackupsTypeFeatureRow(
messageBackupsTypeFeature = it,
iconTint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, top = 6.dp)
)
}
}
}
RemoteRestoreViewModel.ScreenState.LoadState.FAILURE -> {
RestoreFailedDialog(onDismiss = onCancelClick)
}
}
when (state.importState) {
RemoteRestoreViewModel.ImportState.None -> Unit
RemoteRestoreViewModel.ImportState.InProgress -> RestoreProgressDialog(state.restoreProgress)
is RemoteRestoreViewModel.ImportState.Restored -> Unit
RemoteRestoreViewModel.ImportState.Failed -> RestoreFailedDialog(onDismiss = onErrorDialogDismiss)
}
}
}
@SignalPreview
@Composable
private fun RestoreFromBackupContentPreview() {
Previews.Preview {
RestoreFromBackupContent(
state = RemoteRestoreViewModel.ScreenState(
backupTier = MessageBackupTier.PAID,
backupTime = System.currentTimeMillis(),
importState = RemoteRestoreViewModel.ImportState.None,
restoreProgress = null
)
)
}
}
@SignalPreview
@Composable
private fun RestoreFromBackupContentLoadingPreview() {
Previews.Preview {
RestoreFromBackupContent(
state = RemoteRestoreViewModel.ScreenState(
importState = RemoteRestoreViewModel.ImportState.None,
restoreProgress = null
)
)
}
}
@Composable
private fun getFeatures(tier: MessageBackupTier?): ImmutableList<MessageBackupsTypeFeature> {
return when (tier) {
null -> persistentListOf()
MessageBackupTier.PAID -> {
persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_media)
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_recent_compact_bold_16,
label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_messages)
)
)
}
MessageBackupTier.FREE -> {
persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = stringResource(id = R.string.RemoteRestoreActivity__your_last_d_days_of_media, 30)
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_recent_compact_bold_16,
label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_messages)
)
)
}
}
}
/**
* A dialog that *just* shows a spinner. Useful for short actions where you need to
* let the user know that some action is completing.
*/
@Composable
private fun RestoreProgressDialog(restoreProgress: RestoreV2Event?) {
androidx.compose.material3.AlertDialog(
onDismissRequest = {},
confirmButton = {},
dismissButton = {},
text = {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.wrapContentSize()
) {
if (restoreProgress == null) {
CircularProgressIndicator(
modifier = Modifier
.padding(top = 55.dp, bottom = 16.dp)
.width(48.dp)
.height(48.dp)
)
} else {
CircularProgressIndicator(
progress = { restoreProgress.getProgress() },
modifier = Modifier
.padding(top = 55.dp, bottom = 16.dp)
.width(48.dp)
.height(48.dp)
)
}
val progressText = when (restoreProgress?.type) {
RestoreV2Event.Type.PROGRESS_DOWNLOAD -> stringResource(id = R.string.RemoteRestoreActivity__downloading_backup)
RestoreV2Event.Type.PROGRESS_RESTORE -> stringResource(id = R.string.RemoteRestoreActivity__downloading_backup)
else -> stringResource(id = R.string.RemoteRestoreActivity__restoring)
}
Text(
text = progressText,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 12.dp)
)
if (restoreProgress != null) {
val progressBytes = restoreProgress.count.toUnitString(maxPlaces = 2)
val totalBytes = restoreProgress.estimatedTotalCount.toUnitString(maxPlaces = 2)
Text(
text = stringResource(id = R.string.RemoteRestoreActivity__s_of_s_s, progressBytes, totalBytes, "%.2f%%".format(restoreProgress.getProgress())),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 12.dp)
)
}
}
}
},
modifier = Modifier.width(212.dp)
)
}
@SignalPreview
@Composable
private fun ProgressDialogPreview() {
Previews.Preview {
RestoreProgressDialog(
RestoreV2Event(
type = RestoreV2Event.Type.PROGRESS_RESTORE,
count = 1234.bytes,
estimatedTotalCount = 10240.bytes
)
)
}
}
@Composable
fun RestoreFailedDialog(
onDismiss: () -> Unit = {}
) {
Dialogs.SimpleAlertDialog(
title = "Restore Failed", // TODO [backups] Remote restore error placeholder copy
body = "Unable to restore from backup. Please try again.", // TODO [backups] Placeholder copy
confirm = stringResource(android.R.string.ok),
onConfirm = onDismiss,
onDismiss = onDismiss
)
}
@SignalPreview
@Composable
private fun RestoreFailedDialogPreview() {
Previews.Preview {
RestoreFailedDialog()
}
}

View file

@ -0,0 +1,159 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.restore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.BackupRestoreJob
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.jobs.SyncArchivedMediaJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
class RemoteRestoreViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(RemoteRestoreViewModel::class)
}
private val store: MutableStateFlow<ScreenState> = MutableStateFlow(
ScreenState(
backupTier = SignalStore.backup.backupTier,
backupTime = SignalStore.backup.lastBackupTime
)
)
val state: StateFlow<ScreenState> = store.asStateFlow()
init {
viewModelScope.launch(Dispatchers.IO) {
val restored = BackupRepository.restoreBackupTier(SignalStore.account.requireAci()) != null
store.update {
if (restored) {
it.copy(
loadState = ScreenState.LoadState.LOADED,
backupTier = SignalStore.backup.backupTier,
backupTime = SignalStore.backup.lastBackupTime
)
} else {
it.copy(
loadState = ScreenState.LoadState.FAILURE
)
}
}
}
}
fun restore() {
viewModelScope.launch {
store.update { it.copy(importState = ImportState.InProgress) }
withContext(Dispatchers.IO) {
val jobStateFlow = callbackFlow {
val listener = JobTracker.JobListener { _, jobState ->
trySend(jobState)
}
AppDependencies
.jobManager
.startChain(BackupRestoreJob())
.then(SyncArchivedMediaJob())
.then(BackupRestoreMediaJob())
.enqueue(listener)
awaitClose {
AppDependencies.jobManager.removeListener(listener)
}
}
jobStateFlow.collect { state ->
when (state) {
JobTracker.JobState.SUCCESS -> {
Log.i(TAG, "Restore successful")
SignalStore.registration.markRestoreCompleted()
if (!RegistrationRepository.isMissingProfileData()) {
RegistrationUtil.maybeMarkRegistrationComplete()
AppDependencies.jobManager.add(ProfileUploadJob())
}
store.update { it.copy(importState = ImportState.Restored(RegistrationRepository.isMissingProfileData())) }
}
JobTracker.JobState.PENDING,
JobTracker.JobState.RUNNING -> {
Log.i(TAG, "Restore job states updated: $state")
}
JobTracker.JobState.FAILURE,
JobTracker.JobState.IGNORED -> {
Log.w(TAG, "Restore failed with $state")
store.update { it.copy(importState = ImportState.Failed) }
}
}
}
}
}
}
fun updateRestoreProgress(restoreEvent: RestoreV2Event) {
store.update { it.copy(restoreProgress = restoreEvent) }
}
fun cancel() {
SignalStore.registration.markSkippedTransferOrRestore()
}
fun clearError() {
store.update { it.copy(importState = ImportState.None, restoreProgress = null) }
}
data class ScreenState(
val backupTier: MessageBackupTier? = null,
val backupTime: Long = -1,
val importState: ImportState = ImportState.None,
val restoreProgress: RestoreV2Event? = null,
val loadState: LoadState = if (backupTier != null) LoadState.LOADED else LoadState.LOADING
) {
fun isLoaded(): Boolean {
return loadState == LoadState.LOADED
}
fun isLoading(): Boolean {
return loadState == LoadState.LOADING
}
enum class LoadState {
LOADING, LOADED, FAILURE
}
}
sealed interface ImportState {
data object None : ImportState
data object InProgress : ImportState
data class Restored(val missingProfileData: Boolean) : ImportState
data object Failed : ImportState
}
}

View file

@ -0,0 +1,34 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.restore
import org.thoughtcrime.securesms.R
/**
* Restore methods for various spots in restore flow.
*/
enum class RestoreMethod(val iconRes: Int, val titleRes: Int, val subtitleRes: Int) {
FROM_SIGNAL_BACKUPS(
iconRes = R.drawable.symbol_signal_backups_24,
titleRes = R.string.SelectRestoreMethodFragment__from_signal_backups,
subtitleRes = R.string.SelectRestoreMethodFragment__your_free_or_paid_signal_backup_plan
),
FROM_LOCAL_BACKUP_V1(
iconRes = R.drawable.symbol_file_24,
titleRes = R.string.SelectRestoreMethodFragment__from_a_backup_file,
subtitleRes = R.string.SelectRestoreMethodFragment__choose_a_backup_youve_saved
),
FROM_LOCAL_BACKUP_V2(
iconRes = R.drawable.symbol_folder_24,
titleRes = R.string.SelectRestoreMethodFragment__from_a_backup_folder,
subtitleRes = R.string.SelectRestoreMethodFragment__choose_a_backup_youve_saved
),
FROM_OLD_DEVICE(
iconRes = R.drawable.symbol_transfer_24,
titleRes = R.string.SelectRestoreMethodFragment__from_your_old_phone,
subtitleRes = R.string.SelectRestoreMethodFragment__transfer_directly_from_old
)
}

View file

@ -0,0 +1,86 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.restore
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
/**
* Renders row-ux used commonly through the restore flows.
*/
@Composable
fun RestoreRow(
icon: Painter,
title: String,
subtitle: String,
onRowClick: () -> Unit = {}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(bottom = 16.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(SignalTheme.colors.colorSurface2)
.clickable(enabled = true, onClick = onRowClick)
.padding(horizontal = 20.dp, vertical = 22.dp)
) {
Icon(
painter = icon,
tint = MaterialTheme.colorScheme.primary,
contentDescription = null,
modifier = Modifier.size(48.dp)
)
Column(
modifier = Modifier.padding(start = 16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@SignalPreview
@Composable
private fun RestoreMethodRowPreview() {
Previews.Preview {
RestoreRow(
icon = painterResource(R.drawable.symbol_backup_24),
title = stringResource(R.string.SelectRestoreMethodFragment__from_signal_backups),
subtitle = stringResource(R.string.SelectRestoreMethodFragment__your_free_or_paid_signal_backup_plan)
)
}
}

View file

@ -0,0 +1,350 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.restore
import android.os.Bundle
import android.view.View
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.horizontalGutters
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCode
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
/**
* Show QR code on new device to allow registration and restore via old device.
*/
class RestoreViaQrFragment : ComposeFragment() {
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val viewModel: RestoreViaQrViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
viewModel
.state
.mapNotNull { it.provisioningMessage }
.distinctUntilChanged()
.collect { message ->
sharedViewModel.registerWithBackupKey(requireContext(), message.accountEntropyPool, message.e164, message.pin)
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
sharedViewModel
.state
.map { it.registerAccountError }
.filterNotNull()
.collect {
sharedViewModel.registerAccountErrorShown()
viewModel.handleRegistrationFailure()
}
}
}
}
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsState()
RestoreViaQrScreen(
state = state,
onRetryQrCode = viewModel::restart,
onRegistrationErrorDismiss = viewModel::clearRegistrationError,
onCancel = { findNavController().popBackStack() }
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RestoreViaQrScreen(
state: RestoreViaQrViewModel.RestoreViaQrState,
onRetryQrCode: () -> Unit = {},
onRegistrationErrorDismiss: () -> Unit = {},
onCancel: () -> Unit = {}
) {
RegistrationScreen(
title = stringResource(R.string.RestoreViaQr_title),
subtitle = null,
bottomContent = {
TextButton(
onClick = onCancel,
modifier = Modifier.align(Alignment.Center)
) {
Text(text = stringResource(android.R.string.cancel))
}
}
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(space = 48.dp, alignment = Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(space = 48.dp),
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
) {
Box(
modifier = Modifier
.widthIn(160.dp, 320.dp)
.aspectRatio(1f)
.clip(RoundedCornerShape(24.dp))
.background(SignalTheme.colors.colorSurface5)
.padding(40.dp)
) {
SignalTheme(isDarkMode = false) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface)
.fillMaxWidth()
.fillMaxHeight()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
AnimatedContent(
targetState = state.qrState,
contentAlignment = Alignment.Center,
label = "qr-code-progress"
) { qrState ->
when (qrState) {
is RestoreViaQrViewModel.QrState.Loaded -> {
QrCode(
data = qrState.qrData,
foregroundColor = Color(0xFF2449C0),
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
)
}
RestoreViaQrViewModel.QrState.Loading -> {
CircularProgressIndicator(modifier = Modifier.size(48.dp))
}
is RestoreViaQrViewModel.QrState.Scanned,
RestoreViaQrViewModel.QrState.Failed -> {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
val text = if (state.qrState is RestoreViaQrViewModel.QrState.Scanned) {
stringResource(R.string.RestoreViaQr_qr_code_scanned)
} else {
stringResource(R.string.RestoreViaQr_qr_code_error)
}
Text(
text = text,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Buttons.Small(
onClick = onRetryQrCode
) {
Text(text = stringResource(R.string.RestoreViaQr_retry))
}
}
}
}
}
}
}
}
Column(
modifier = Modifier
.align(alignment = Alignment.CenterVertically)
.widthIn(160.dp, 320.dp)
) {
InstructionRow(
icon = painterResource(R.drawable.symbol_phone_24),
instruction = stringResource(R.string.RestoreViaQr_instruction_1)
)
InstructionRow(
icon = painterResource(R.drawable.symbol_camera_24),
instruction = stringResource(R.string.RestoreViaQr_instruction_2)
)
InstructionRow(
icon = painterResource(R.drawable.symbol_qrcode_24),
instruction = stringResource(R.string.RestoreViaQr_instruction_3)
)
}
}
if (state.isRegistering) {
Dialogs.IndeterminateProgressDialog()
} else if (state.showRegistrationError) {
Dialogs.SimpleMessageDialog(
message = stringResource(R.string.RegistrationActivity_error_connecting_to_service),
onDismiss = onRegistrationErrorDismiss,
dismiss = stringResource(android.R.string.ok)
)
}
}
}
@SignalPreview
@Composable
private fun RestoreViaQrScreenPreview() {
Previews.Preview {
RestoreViaQrScreen(
state = RestoreViaQrViewModel.RestoreViaQrState(
qrState = RestoreViaQrViewModel.QrState.Loaded(
QrCodeData.forData("sgnl://rereg?uuid=asdfasdfasdfasdfasdfasdf&pub_key=asdfasdfasdfSDFSsdfsdfSDFSDffd", false)
)
)
)
}
}
@SignalPreview
@Composable
private fun RestoreViaQrScreenLoadingPreview() {
Previews.Preview {
RestoreViaQrScreen(
state = RestoreViaQrViewModel.RestoreViaQrState(qrState = RestoreViaQrViewModel.QrState.Loading)
)
}
}
@SignalPreview
@Composable
private fun RestoreViaQrScreenFailurePreview() {
Previews.Preview {
RestoreViaQrScreen(
state = RestoreViaQrViewModel.RestoreViaQrState(qrState = RestoreViaQrViewModel.QrState.Failed)
)
}
}
@SignalPreview
@Composable
private fun RestoreViaQrScreenScannedPreview() {
Previews.Preview {
RestoreViaQrScreen(
state = RestoreViaQrViewModel.RestoreViaQrState(qrState = RestoreViaQrViewModel.QrState.Scanned)
)
}
}
@SignalPreview
@Composable
private fun RestoreViaQrScreenRegisteringPreview() {
Previews.Preview {
RestoreViaQrScreen(
state = RestoreViaQrViewModel.RestoreViaQrState(isRegistering = true, qrState = RestoreViaQrViewModel.QrState.Scanned)
)
}
}
@SignalPreview
@Composable
private fun RestoreViaQrScreenRegistrationFailedPreview() {
Previews.Preview {
RestoreViaQrScreen(
state = RestoreViaQrViewModel.RestoreViaQrState(isRegistering = false, showRegistrationError = true, qrState = RestoreViaQrViewModel.QrState.Scanned)
)
}
}
@Composable
private fun InstructionRow(
icon: Painter,
instruction: String
) {
Row(
modifier = Modifier
.padding(vertical = 12.dp)
) {
Icon(
painter = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = instruction,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@SignalPreview
@Composable
private fun InstructionRowPreview() {
Previews.Preview {
InstructionRow(
icon = painterResource(R.drawable.symbol_phone_24),
instruction = "Instruction!"
)
}
}

View file

@ -0,0 +1,94 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.restore
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.signal.registration.proto.RegistrationProvisionMessage
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.whispersystems.signalservice.api.registration.ProvisioningSocket
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher
import java.io.Closeable
class RestoreViaQrViewModel : ViewModel() {
private val store: MutableStateFlow<RestoreViaQrState> = MutableStateFlow(RestoreViaQrState())
val state: StateFlow<RestoreViaQrState> = store
private var socketHandle: Closeable
init {
socketHandle = start()
}
fun restart() {
socketHandle.close()
socketHandle = start()
}
fun handleRegistrationFailure() {
store.update {
if (it.isRegistering) {
it.copy(
isRegistering = false,
provisioningMessage = null,
showRegistrationError = true
)
} else {
it
}
}
}
fun clearRegistrationError() {
store.update { it.copy(showRegistrationError = false) }
}
override fun onCleared() {
socketHandle.close()
}
private fun start(): Closeable {
store.update { it.copy(qrState = QrState.Loading) }
return ProvisioningSocket.start(
identityKeyPair = IdentityKeyUtil.generateIdentityKeyPair(),
configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration(),
handler = CoroutineExceptionHandler { _, _ -> store.update { it.copy(qrState = QrState.Failed) } }
) { socket ->
val url = socket.getProvisioningUrl()
store.update { it.copy(qrState = QrState.Loaded(qrData = QrCodeData.forData(data = url, supportIconOverlay = false))) }
val result = socket.getRegistrationProvisioningMessage()
if (result is SecondaryProvisioningCipher.RegistrationProvisionResult.Success) {
store.update { it.copy(isRegistering = true, provisioningMessage = result.message, qrState = QrState.Scanned) }
} else {
store.update { it.copy(showProvisioningError = true, qrState = QrState.Scanned) }
}
}
}
data class RestoreViaQrState(
val isRegistering: Boolean = false,
val qrState: QrState = QrState.Loading,
val provisioningMessage: RegistrationProvisionMessage? = null,
val showProvisioningError: Boolean = false,
val showRegistrationError: Boolean = false
)
sealed interface QrState {
data object Loading : QrState
data class Loaded(val qrData: QrCodeData) : QrState
data object Failed : QrState
data object Scanned : QrState
}
}

View file

@ -0,0 +1,62 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.restore
import android.app.Activity
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode
import org.thoughtcrime.securesms.restore.RestoreActivity
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Provide options to select restore/transfer operation and flow during manual registration.
*/
class SelectManualRestoreMethodFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(SelectManualRestoreMethodFragment::class)
}
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val launchRestoreActivity = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
when (val resultCode = result.resultCode) {
Activity.RESULT_OK -> {
sharedViewModel.onBackupSuccessfullyRestored()
findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL))
}
Activity.RESULT_CANCELED -> {
Log.w(TAG, "Backup restoration canceled.")
}
else -> Log.w(TAG, "Backup restoration activity ended with unknown result code: $resultCode")
}
}
@Composable
override fun FragmentContent() {
SelectRestoreMethodScreen(
restoreMethods = listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_LOCAL_BACKUP_V1),
onRestoreMethodClicked = this::startRestoreMethod,
onSkip = { findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL)) }
)
}
private fun startRestoreMethod(method: RestoreMethod) {
when (method) {
RestoreMethod.FROM_SIGNAL_BACKUPS -> findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE))
RestoreMethod.FROM_LOCAL_BACKUP_V1 -> launchRestoreActivity.launch(RestoreActivity.getLocalRestoreIntent(requireContext()))
RestoreMethod.FROM_OLD_DEVICE -> error("Device transfer not supported in manual restore flow")
RestoreMethod.FROM_LOCAL_BACKUP_V2 -> error("Not currently supported")
}
}
}

View file

@ -0,0 +1,58 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.restore
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
/**
* Screen showing various restore methods available during quick and manual re-registration.
*/
@Composable
fun SelectRestoreMethodScreen(
restoreMethods: List<RestoreMethod>,
onRestoreMethodClicked: (RestoreMethod) -> Unit = {},
onSkip: () -> Unit = {}
) {
RegistrationScreen(
title = stringResource(id = R.string.SelectRestoreMethodFragment__restore_or_transfer_account),
subtitle = stringResource(id = R.string.SelectRestoreMethodFragment__get_your_signal_account),
bottomContent = {
TextButton(
onClick = onSkip,
modifier = Modifier.align(Alignment.Center)
) {
Text(text = stringResource(R.string.registration_activity__skip))
}
}
) {
for (method in restoreMethods) {
RestoreRow(
icon = painterResource(method.iconRes),
title = stringResource(method.titleRes),
subtitle = stringResource(method.subtitleRes),
onRowClick = { onRestoreMethodClicked(method) }
)
}
}
}
@SignalPreview
@Composable
private fun SelectRestoreMethodScreenPreview() {
SignalTheme {
SelectRestoreMethodScreen(listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_OLD_DEVICE, RestoreMethod.FROM_LOCAL_BACKUP_V1))
}
}

View file

@ -0,0 +1,122 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.shared
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.horizontalGutters
/**
* A base framework for rendering the various v3 registration screens.
*/
@Composable
fun RegistrationScreen(
title: String,
subtitle: String,
bottomContent: @Composable (BoxScope.() -> Unit),
mainContent: @Composable () -> Unit
) {
RegistrationScreen(title, AnnotatedString(subtitle), bottomContent, mainContent)
}
/**
* A base framework for rendering the various v3 registration screens.
*/
@Composable
fun RegistrationScreen(
title: String,
subtitle: AnnotatedString?,
bottomContent: @Composable (BoxScope.() -> Unit),
mainContent: @Composable () -> Unit
) {
Surface {
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.verticalScroll(scrollState)
.weight(weight = 1f, fill = false)
.padding(top = 40.dp, bottom = 16.dp)
.horizontalGutters()
) {
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp)
)
}
Spacer(modifier = Modifier.height(40.dp))
mainContent()
}
Surface(
shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp,
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.padding(top = 8.dp, bottom = 24.dp)
.horizontalGutters()
) {
bottomContent()
}
}
}
}
}
@SignalPreview
@Composable
private fun RegistrationScreenPreview() {
Previews.Preview {
RegistrationScreen(
title = "Title",
subtitle = "Subtitle",
bottomContent = {
TextButton(onClick = {}) {
Text("Bottom Button")
}
}
) {
Text("Main content")
}
}
}

View file

@ -0,0 +1,164 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.welcome
import android.content.DialogInterface
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
/**
* Restore flow starting bottom sheet that allows user to progress through quick restore or manual restore flows
* from the Welcome screen.
*/
class RestoreWelcomeBottomSheet : ComposeBottomSheetDialogFragment() {
private var result: WelcomeUserSelection = WelcomeUserSelection.CONTINUE
companion object {
const val REQUEST_KEY = "RestoreWelcomeBottomSheet"
}
@Composable
override fun SheetContent() {
Sheet(
onHasOldPhone = {
result = WelcomeUserSelection.RESTORE_WITH_OLD_PHONE
dismissAllowingStateLoss()
},
onNoPhone = {
result = WelcomeUserSelection.RESTORE_WITH_NO_PHONE
dismissAllowingStateLoss()
}
)
}
override fun onDismiss(dialog: DialogInterface) {
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to result))
super.onDismiss(dialog)
}
}
@Composable
private fun Sheet(
onHasOldPhone: () -> Unit = {},
onNoPhone: () -> Unit = {}
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
.padding(bottom = 54.dp)
) {
BottomSheets.Handle()
val context = LocalContext.current
Spacer(modifier = Modifier.size(26.dp))
RestoreActionRow(
icon = painterResource(R.drawable.symbol_qrcode_24),
title = stringResource(R.string.WelcomeFragment_restore_action_i_have_my_old_phone),
subtitle = stringResource(R.string.WelcomeFragment_restore_action_scan_qr),
onRowClick = onHasOldPhone
)
RestoreActionRow(
icon = painterResource(R.drawable.symbol_no_phone_44),
title = stringResource(R.string.WelcomeFragment_restore_action_i_dont_have_my_old_phone),
subtitle = stringResource(R.string.WelcomeFragment_restore_action_reinstalling),
onRowClick = onNoPhone
)
}
}
@Composable
@SignalPreview
private fun SheetPreview() {
Previews.BottomSheetPreview {
Sheet()
}
}
@Composable
fun RestoreActionRow(
icon: Painter,
title: String,
subtitle: String,
onRowClick: () -> Unit = {}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.horizontalGutters()
.padding(vertical = 8.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(MaterialTheme.colorScheme.background)
.clickable(enabled = true, onClick = onRowClick)
.padding(horizontal = 24.dp, vertical = 16.dp)
) {
Icon(
painter = icon,
tint = MaterialTheme.colorScheme.primary,
contentDescription = null,
modifier = Modifier.size(44.dp)
)
Column(
modifier = Modifier.padding(start = 16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@SignalPreview
@Composable
private fun RestoreActionRowPreview() {
Previews.Preview {
RestoreActionRow(
icon = painterResource(R.drawable.symbol_qrcode_24),
title = stringResource(R.string.WelcomeFragment_restore_action_i_have_my_old_phone),
subtitle = stringResource(R.string.WelcomeFragment_restore_action_scan_qr)
)
}
}

View file

@ -0,0 +1,120 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.welcome
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.View
import androidx.core.content.ContextCompat
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import org.signal.core.util.getSerializableCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentRegistrationWelcomeV3Binding
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
import org.thoughtcrime.securesms.registration.fragments.WelcomePermissions
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registrationv3.ui.permissions.GrantPermissionsFragment
import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* First screen that is displayed on the very first app launch.
*/
class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome_v3) {
companion object {
private val TAG = Log.tag(WelcomeFragment::class.java)
private const val TERMS_AND_CONDITIONS_URL = "https://signal.org/legal"
}
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val binding: FragmentRegistrationWelcomeV3Binding by ViewBinderDelegate(FragmentRegistrationWelcomeV3Binding::bind)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setDebugLogSubmitMultiTapView(binding.image)
setDebugLogSubmitMultiTapView(binding.title)
binding.welcomeContinueButton.setOnClickListener { onContinueClicked() }
binding.welcomeTermsButton.setOnClickListener { onTermsClicked() }
binding.welcomeTransferOrRestore.setOnClickListener { onRestoreOrTransferClicked() }
childFragmentManager.setFragmentResultListener(RestoreWelcomeBottomSheet.REQUEST_KEY, viewLifecycleOwner) { requestKey, bundle ->
if (requestKey == RestoreWelcomeBottomSheet.REQUEST_KEY) {
when (val userSelection = bundle.getSerializableCompat(RestoreWelcomeBottomSheet.REQUEST_KEY, WelcomeUserSelection::class.java)) {
WelcomeUserSelection.RESTORE_WITH_OLD_PHONE,
WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> afterRestoreOrTransferClicked(userSelection)
else -> Unit
}
}
}
if (Permissions.isRuntimePermissionsRequired()) {
parentFragmentManager.setFragmentResultListener(GrantPermissionsFragment.REQUEST_KEY, viewLifecycleOwner) { requestKey, bundle ->
if (requestKey == GrantPermissionsFragment.REQUEST_KEY) {
when (val userSelection = bundle.getSerializableCompat(GrantPermissionsFragment.REQUEST_KEY, WelcomeUserSelection::class.java)) {
WelcomeUserSelection.RESTORE_WITH_OLD_PHONE,
WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> navigateToNextScreenViaRestore(userSelection)
WelcomeUserSelection.CONTINUE -> navigateToNextScreenViaContinue()
null -> Unit
}
}
}
}
}
private fun onContinueClicked() {
if (Permissions.isRuntimePermissionsRequired() && !hasAllPermissions()) {
findNavController().safeNavigate(WelcomeFragmentDirections.actionWelcomeFragmentToGrantPermissionsFragment(WelcomeUserSelection.CONTINUE))
} else {
navigateToNextScreenViaContinue()
}
}
private fun navigateToNextScreenViaContinue() {
sharedViewModel.maybePrefillE164(requireContext())
findNavController().safeNavigate(WelcomeFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL))
}
private fun onTermsClicked() {
CommunicationActions.openBrowserLink(requireContext(), TERMS_AND_CONDITIONS_URL)
}
private fun onRestoreOrTransferClicked() {
RestoreWelcomeBottomSheet().show(childFragmentManager, null)
}
private fun afterRestoreOrTransferClicked(userSelection: WelcomeUserSelection) {
if (Permissions.isRuntimePermissionsRequired() && !hasAllPermissions()) {
findNavController().safeNavigate(WelcomeFragmentDirections.actionWelcomeFragmentToGrantPermissionsFragment(userSelection))
} else {
navigateToNextScreenViaRestore(userSelection)
}
}
private fun navigateToNextScreenViaRestore(userSelection: WelcomeUserSelection) {
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED)
when (userSelection) {
WelcomeUserSelection.CONTINUE -> throw IllegalArgumentException()
WelcomeUserSelection.RESTORE_WITH_OLD_PHONE -> findNavController().safeNavigate(WelcomeFragmentDirections.goToRestoreViaQr())
WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> findNavController().safeNavigate(WelcomeFragmentDirections.goToSelectRestoreMethod(userSelection))
}
}
private fun hasAllPermissions(): Boolean {
val isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext())
return WelcomePermissions.getWelcomePermissions(isUserSelectionRequired).all { ContextCompat.checkSelfPermission(requireContext(), it) == PackageManager.PERMISSION_GRANTED }
}
}

View file

@ -0,0 +1,13 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.welcome
/**
* User options available to start registration flow.
*/
enum class WelcomeUserSelection {
CONTINUE, RESTORE_WITH_OLD_PHONE, RESTORE_WITH_NO_PHONE
}

View file

@ -8,16 +8,20 @@ package org.thoughtcrime.securesms.restore
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.navigation.findNavController
import androidx.navigation.NavController
import androidx.navigation.Navigation
import androidx.navigation.fragment.NavHostFragment
import org.signal.core.util.getParcelableExtraCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.ui.restore.RemoteRestoreActivity
import org.thoughtcrime.securesms.RestoreDirections
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Activity to hold the restore from backup flow.
@ -27,6 +31,8 @@ class RestoreActivity : BaseActivity() {
private val dynamicTheme = DynamicNoActionBarTheme()
private val sharedViewModel: RestoreViewModel by viewModels()
private lateinit var navController: NavController
override fun onCreate(savedInstanceState: Bundle?) {
dynamicTheme.onCreate(this)
super.onCreate(savedInstanceState)
@ -34,16 +40,42 @@ class RestoreActivity : BaseActivity() {
setResult(RESULT_CANCELED)
setContentView(R.layout.activity_restore)
if (savedInstanceState == null) {
val fragment: NavHostFragment = NavHostFragment.create(R.navigation.restore)
supportFragmentManager
.beginTransaction()
.replace(R.id.nav_host_fragment, fragment)
.commitNow()
navController = fragment.navController
} else {
val fragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
navController = fragment.navController
}
intent.getParcelableExtraCompat(PassphraseRequiredActivity.NEXT_INTENT_EXTRA, Intent::class.java)?.let {
sharedViewModel.setNextIntent(it)
}
val navTarget = NavTarget.deserialize(intent.getIntExtra(EXTRA_NAV_TARGET, NavTarget.NONE.value))
val navTarget = NavTarget.deserialize(intent.getIntExtra(EXTRA_NAV_TARGET, NavTarget.LEGACY_LANDING.value))
when (navTarget) {
NavTarget.LOCAL_RESTORE -> findNavController(R.id.nav_host_fragment).navigate(R.id.choose_local_backup_fragment)
NavTarget.TRANSFER -> findNavController(R.id.nav_host_fragment).navigate(R.id.newDeviceTransferInstructions)
NavTarget.NEW_LANDING -> navController.safeNavigate(RestoreDirections.goDirectlyToNewLanding())
NavTarget.LOCAL_RESTORE -> navController.safeNavigate(RestoreDirections.goDirectlyToChooseLocalBackup())
NavTarget.TRANSFER -> navController.safeNavigate(RestoreDirections.goDirectlyToDeviceTransfer())
else -> Unit
}
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
onNavigateUp()
}
}
)
}
override fun onResume() {
@ -51,21 +83,38 @@ class RestoreActivity : BaseActivity() {
dynamicTheme.onResume(this)
}
fun finishActivitySuccessfully() {
override fun onNavigateUp(): Boolean {
return if (!Navigation.findNavController(this, R.id.nav_host_fragment).popBackStack()) {
finish()
true
} else {
false
}
}
fun onBackupCompletedSuccessfully() {
sharedViewModel.getNextIntent()?.let {
Log.d(TAG, "Launching ${it.component}", Throwable())
startActivity(it)
}
setResult(RESULT_OK)
finish()
}
companion object {
private val TAG = Log.tag(RestoreActivity::class)
enum class NavTarget(val value: Int) {
NONE(0),
TRANSFER(1),
LOCAL_RESTORE(2);
LEGACY_LANDING(0),
NEW_LANDING(1),
TRANSFER(2),
LOCAL_RESTORE(3);
companion object {
fun deserialize(value: Int): NavTarget {
return entries.firstOrNull { it.value == value } ?: NONE
return entries.firstOrNull { it.value == value } ?: LEGACY_LANDING
}
}
}
@ -73,26 +122,26 @@ class RestoreActivity : BaseActivity() {
private const val EXTRA_NAV_TARGET = "nav_target"
@JvmStatic
fun getIntentForTransfer(context: Context): Intent {
fun getDeviceTransferIntent(context: Context): Intent {
return Intent(context, RestoreActivity::class.java).apply {
putExtra(EXTRA_NAV_TARGET, NavTarget.TRANSFER.value)
}
}
@JvmStatic
fun getIntentForLocalRestore(context: Context): Intent {
fun getLocalRestoreIntent(context: Context): Intent {
return Intent(context, RestoreActivity::class.java).apply {
putExtra(EXTRA_NAV_TARGET, NavTarget.LOCAL_RESTORE.value)
}
}
@JvmStatic
fun getIntentForTransferOrRestore(context: Context): Intent {
val tier = SignalStore.backup.backupTier
if (tier == MessageBackupTier.PAID) {
return Intent(context, RemoteRestoreActivity::class.java)
fun getRestoreIntent(context: Context): Intent {
return Intent(context, RestoreActivity::class.java).apply {
if (RemoteConfig.restoreAfterRegistration) {
putExtra(EXTRA_NAV_TARGET, NavTarget.NEW_LANDING.value)
}
}
return Intent(context, RestoreActivity::class.java)
}
}
}

Some files were not shown because too many files have changed in this diff Show more