mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-04 12:24:00 +01:00
Add relink support and fix sad paths in all link flows.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
This commit is contained in:
@@ -13,9 +13,11 @@ import androidx.core.content.ContextCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.signal.core.util.AppForegroundObserver;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.core.util.tracing.Tracer;
|
||||
import org.signal.devicetransfer.TransferStatus;
|
||||
import org.signal.registration.RegistrationRoute;
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberLockActivity;
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
@@ -30,11 +32,10 @@ import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity;
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
|
||||
import org.thoughtcrime.securesms.util.Environment;
|
||||
import org.thoughtcrime.securesms.restore.RestoreActivity;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.signal.core.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.Environment;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import java.util.Locale;
|
||||
@@ -57,6 +58,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
private static final int STATE_TRANSFER_LOCKED = 9;
|
||||
private static final int STATE_CHANGE_NUMBER_LOCK = 10;
|
||||
private static final int STATE_TRANSFER_OR_RESTORE = 11;
|
||||
private static final int STATE_RESUME_LINKING_REG = 12;
|
||||
|
||||
private SignalServiceNetworkAccess networkAccess;
|
||||
private BroadcastReceiver clearKeyReceiver;
|
||||
@@ -155,6 +157,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
case STATE_TRANSFER_LOCKED: return getOldDeviceTransferLockedIntent();
|
||||
case STATE_CHANGE_NUMBER_LOCK: return getChangeNumberLockIntent();
|
||||
case STATE_TRANSFER_OR_RESTORE: return getTransferOrRestoreIntent();
|
||||
case STATE_RESUME_LINKING_REG: return getResumeLinkedRegistrationIntent();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -168,6 +171,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return STATE_UI_BLOCKING_UPGRADE;
|
||||
} else if (!TextSecurePreferences.hasPromptedPushRegistration(this)) {
|
||||
return STATE_WELCOME_PUSH_SCREEN;
|
||||
} else if (shouldResumeLinkingRegistration()) {
|
||||
return STATE_RESUME_LINKING_REG;
|
||||
} else if (userCanTransferOrRestore()) {
|
||||
return STATE_TRANSFER_OR_RESTORE;
|
||||
} else if (SignalStore.storageService().getNeedsAccountRestore()) {
|
||||
@@ -192,6 +197,14 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
RestoreDecisionStateUtil.isDecisionPending(SignalStore.registration().getRestoreDecisionState());
|
||||
}
|
||||
|
||||
private boolean shouldResumeLinkingRegistration() {
|
||||
return Environment.USE_NEW_REGISTRATION &&
|
||||
SignalStore.account().isRegistered() &&
|
||||
!SignalStore.account().isPrimaryDevice() &&
|
||||
!SignalStore.registration().isRegistrationComplete() &&
|
||||
RestoreDecisionStateUtil.isDecisionPending(SignalStore.registration().getRestoreDecisionState());
|
||||
}
|
||||
|
||||
private boolean userMustCreateSignalPin() {
|
||||
return !SignalStore.registration().isRegistrationComplete() &&
|
||||
!SignalStore.svr().hasPin() &&
|
||||
@@ -246,6 +259,10 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return getRoutedIntent(intent, MainActivity.clearTop(this));
|
||||
}
|
||||
|
||||
private Intent getResumeLinkedRegistrationIntent() {
|
||||
return org.signal.registration.RegistrationActivity.createIntent(this, MainActivity.clearTop(this), RegistrationRoute.MessageSync.INSTANCE);
|
||||
}
|
||||
|
||||
private Intent getCreateProfileNameIntent() {
|
||||
Intent intent = CreateProfileActivity.getIntentForUserProfile(this);
|
||||
return getRoutedIntent(intent, getIntent());
|
||||
|
||||
@@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.messages.IncomingMessageObserver;
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork;
|
||||
import org.thoughtcrime.securesms.transport.RetryLaterException;
|
||||
import org.signal.core.util.PlayServicesUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.whispersystems.signalservice.api.NetworkResultUtil;
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
|
||||
|
||||
@@ -72,6 +73,11 @@ public class FcmRefreshJob extends BaseJob {
|
||||
|
||||
@Override
|
||||
public void onRun() throws Exception {
|
||||
if (TextSecurePreferences.isUnauthorizedReceived(context)) {
|
||||
Log.i(TAG, "No longer authorized. Ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.i(TAG, "Reregistering FCM...");
|
||||
|
||||
boolean playServicesMissing = PlayServicesUtil.getPlayServicesStatus(context) == PlayServicesUtil.PlayServicesStatus.MISSING ;
|
||||
|
||||
@@ -91,6 +91,11 @@ public class RefreshAttributesJob extends BaseJob {
|
||||
return;
|
||||
}
|
||||
|
||||
if (TextSecurePreferences.isUnauthorizedReceived(context)) {
|
||||
Log.i(TAG, "No longer authorized. Ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!forced && hasRefreshedThisAppCycle) {
|
||||
Log.d(TAG, "Already refreshed this app cycle. Skipping.");
|
||||
return;
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.pin.SvrRepository
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import java.io.IOException
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.days
|
||||
@@ -24,7 +25,7 @@ class RefreshSvrCredentialsJob private constructor(parameters: Parameters) : Bas
|
||||
|
||||
@JvmStatic
|
||||
fun enqueueIfNecessary() {
|
||||
if (SignalStore.svr.hasPin() && SignalStore.account.isRegistered) {
|
||||
if (SignalStore.svr.hasPin() && SignalStore.account.isRegistered && !TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application)) {
|
||||
val lastTimestamp = SignalStore.svr.lastRefreshAuthTimestamp
|
||||
if (lastTimestamp + FREQUENCY.inWholeMilliseconds < System.currentTimeMillis() || lastTimestamp > System.currentTimeMillis()) {
|
||||
AppDependencies.jobManager.add(RefreshSvrCredentialsJob())
|
||||
@@ -55,6 +56,11 @@ class RefreshSvrCredentialsJob private constructor(parameters: Parameters) : Bas
|
||||
return
|
||||
}
|
||||
|
||||
if (TextSecurePreferences.isUnauthorizedReceived(context)) {
|
||||
Log.i(TAG, "No longer authorized. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
SvrRepository.refreshAndStoreAuthorization()
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
@@ -44,6 +45,11 @@ class RemoteConfigRefreshJob private constructor(parameters: Parameters) : Job(p
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
if (TextSecurePreferences.isUnauthorizedReceived(context)) {
|
||||
Log.i(TAG, "No longer authorized. Ignoring.")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
return when (val result = SignalNetwork.remoteConfig.getRemoteConfig(SignalStore.remoteConfig.eTag)) {
|
||||
is NetworkResult.Success -> {
|
||||
RemoteConfig.update(result.result.config)
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.keyvalue.CertificateType;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork;
|
||||
import org.thoughtcrime.securesms.util.ExceptionHelper;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.whispersystems.signalservice.api.NetworkResultUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -56,6 +57,11 @@ public final class RotateCertificateJob extends BaseJob {
|
||||
return;
|
||||
}
|
||||
|
||||
if (TextSecurePreferences.isUnauthorizedReceived(context)) {
|
||||
Log.i(TAG, "No longer authorized. Ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
synchronized (RotateCertificateJob.class) {
|
||||
Collection<CertificateType> certificateTypes = SignalStore.phoneNumberPrivacy()
|
||||
.getAllCertificateTypes();
|
||||
|
||||
@@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.storage.StorageSyncValidations
|
||||
import org.thoughtcrime.securesms.storage.StoryDistributionListRecordProcessor
|
||||
import org.thoughtcrime.securesms.transport.RetryLaterException
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage
|
||||
@@ -185,6 +186,11 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
|
||||
return
|
||||
}
|
||||
|
||||
if (TextSecurePreferences.isUnauthorizedReceived(context)) {
|
||||
Log.i(TAG, "No longer authorized. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
if (!Recipient.self().hasE164 || !Recipient.self().hasServiceId) {
|
||||
Log.w(TAG, "Missing E164 or ACI!")
|
||||
return
|
||||
|
||||
+8
-3
@@ -12,6 +12,7 @@ import androidx.activity.viewModels
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.navigation.ActivityNavigator
|
||||
import org.signal.registration.RegistrationRoute
|
||||
import org.thoughtcrime.securesms.BaseActivity
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -84,7 +85,7 @@ class RegistrationActivity : BaseActivity() {
|
||||
@JvmStatic
|
||||
fun newIntentForNewRegistration(context: Context, originalIntent: Intent): Intent {
|
||||
return if (Environment.USE_NEW_REGISTRATION) {
|
||||
org.signal.registration.RegistrationActivity.createIntent(context, MainActivity.clearTop(context))
|
||||
org.signal.registration.RegistrationActivity.createIntent(context, nextIntent = MainActivity.clearTop(context))
|
||||
} else {
|
||||
Intent(context, RegistrationActivity::class.java).apply {
|
||||
putExtra(RE_REGISTRATION_EXTRA, false)
|
||||
@@ -96,7 +97,7 @@ class RegistrationActivity : BaseActivity() {
|
||||
@JvmStatic
|
||||
fun newIntentForReRegistration(context: Context): Intent {
|
||||
return if (Environment.USE_NEW_REGISTRATION) {
|
||||
org.signal.registration.RegistrationActivity.createIntent(context, MainActivity.clearTop(context))
|
||||
org.signal.registration.RegistrationActivity.createIntent(context, nextIntent = MainActivity.clearTop(context))
|
||||
} else {
|
||||
Intent(context, RegistrationActivity::class.java).apply {
|
||||
putExtra(RE_REGISTRATION_EXTRA, true)
|
||||
@@ -107,7 +108,11 @@ class RegistrationActivity : BaseActivity() {
|
||||
@JvmStatic
|
||||
fun newIntentForReLinkDevice(context: Context): Intent {
|
||||
return if (Environment.USE_NEW_REGISTRATION) {
|
||||
org.signal.registration.RegistrationActivity.createIntent(context, MainActivity.clearTop(context))
|
||||
org.signal.registration.RegistrationActivity.createIntent(
|
||||
context = context,
|
||||
nextIntent = MainActivity.clearTop(context),
|
||||
startDestination = RegistrationRoute.LinkAccount(showCreateAccount = false)
|
||||
)
|
||||
} else {
|
||||
Intent(context, RegistrationActivity::class.java)
|
||||
}
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ class RegisterLinkDeviceQrViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
return ProvisioningSocket.start<ProvisionMessage>(
|
||||
mode = ProvisioningSocket.Mode.LINK,
|
||||
mode = ProvisioningSocket.Mode.Link(linkAndSyncCapable = true),
|
||||
identityKeyPair = IdentityKeyPair.generate(),
|
||||
configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration(),
|
||||
handler = { id, t ->
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ class RestoreViaQrViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
return ProvisioningSocket.start<RegistrationProvisionMessage>(
|
||||
mode = ProvisioningSocket.Mode.REREG,
|
||||
mode = ProvisioningSocket.Mode.Rereg,
|
||||
identityKeyPair = IdentityKeyPair.generate(),
|
||||
configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration(),
|
||||
handler = { id, t ->
|
||||
|
||||
+3
-3
@@ -771,7 +771,7 @@ class AppRegistrationNetworkController(
|
||||
|
||||
fun startSocket() {
|
||||
val handle = ProvisioningSocket.start<RegistrationProvisionMessage>(
|
||||
mode = ProvisioningSocket.Mode.REREG,
|
||||
mode = ProvisioningSocket.Mode.Rereg,
|
||||
identityKeyPair = IdentityKeyPair.generate(),
|
||||
configuration = configuration,
|
||||
handler = { id, t ->
|
||||
@@ -848,13 +848,13 @@ class AppRegistrationNetworkController(
|
||||
}
|
||||
}
|
||||
|
||||
override fun startLinkDeviceProvisioning(): Flow<LinkDeviceProvisioningEvent> = callbackFlow {
|
||||
override fun startLinkDeviceProvisioning(allowLinkAndSync: Boolean): Flow<LinkDeviceProvisioningEvent> = callbackFlow {
|
||||
val socketHandles = mutableListOf<Closeable>()
|
||||
val configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration()
|
||||
|
||||
fun startSocket() {
|
||||
val handle = ProvisioningSocket.start<ProvisionMessage>(
|
||||
mode = ProvisioningSocket.Mode.LINK,
|
||||
mode = ProvisioningSocket.Mode.Link(linkAndSyncCapable = allowLinkAndSync),
|
||||
identityKeyPair = IdentityKeyPair.generate(),
|
||||
configuration = configuration,
|
||||
handler = { id, t ->
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
|
||||
val pni = SignalStore.account.pni ?: return@withContext null
|
||||
val e164 = SignalStore.account.e164 ?: return@withContext null
|
||||
val servicePassword = SignalStore.account.servicePassword ?: return@withContext null
|
||||
val aep = SignalStore.account.accountEntropyPool ?: return@withContext null
|
||||
val aep = SignalStore.account.accountEntropyPool
|
||||
|
||||
val aciIdentityKeyPair = SignalStore.account.aciIdentityKey
|
||||
val pniIdentityKeyPair = SignalStore.account.pniIdentityKey
|
||||
|
||||
+2
-2
@@ -248,8 +248,8 @@ class DebugNetworkController(
|
||||
return delegate.startProvisioning()
|
||||
}
|
||||
|
||||
override fun startLinkDeviceProvisioning(): Flow<NetworkController.LinkDeviceProvisioningEvent> {
|
||||
return delegate.startLinkDeviceProvisioning()
|
||||
override fun startLinkDeviceProvisioning(allowLinkAndSync: Boolean): Flow<NetworkController.LinkDeviceProvisioningEvent> {
|
||||
return delegate.startLinkDeviceProvisioning(allowLinkAndSync)
|
||||
}
|
||||
|
||||
override suspend fun registerAsLinkedDevice(
|
||||
|
||||
+3
-3
@@ -435,12 +435,12 @@ class DemoNetworkController(
|
||||
)
|
||||
}
|
||||
|
||||
override fun startLinkDeviceProvisioning(): Flow<NetworkController.LinkDeviceProvisioningEvent> = callbackFlow {
|
||||
override fun startLinkDeviceProvisioning(allowLinkAndSync: Boolean): Flow<NetworkController.LinkDeviceProvisioningEvent> = callbackFlow {
|
||||
val socketHandles = mutableListOf<Closeable>()
|
||||
|
||||
fun startSocket() {
|
||||
val handle = ProvisioningSocket.start<ProvisionMessage>(
|
||||
mode = ProvisioningSocket.Mode.LINK,
|
||||
mode = ProvisioningSocket.Mode.Link(linkAndSyncCapable = allowLinkAndSync),
|
||||
identityKeyPair = IdentityKeyPair.generate(),
|
||||
configuration = serviceConfiguration,
|
||||
handler = { id, t ->
|
||||
@@ -659,7 +659,7 @@ class DemoNetworkController(
|
||||
|
||||
fun startSocket() {
|
||||
val handle = ProvisioningSocket.start<RegistrationProvisionMessage>(
|
||||
mode = ProvisioningSocket.Mode.REREG,
|
||||
mode = ProvisioningSocket.Mode.Rereg,
|
||||
identityKeyPair = IdentityKeyPair.generate(),
|
||||
configuration = serviceConfiguration,
|
||||
handler = { id, t ->
|
||||
|
||||
@@ -240,8 +240,10 @@ interface NetworkController {
|
||||
* - [LinkDeviceProvisioningEvent.Error] if the provisioning session encounters an unrecoverable error.
|
||||
*
|
||||
* The flow manages socket lifecycle (rotation, keep-alive) internally. Cancel the collecting coroutine to stop provisioning.
|
||||
*
|
||||
* @param allowLinkAndSync Whether we allow data sync during linking. Normally allowed, but disabled for re-links.
|
||||
*/
|
||||
fun startLinkDeviceProvisioning(): Flow<LinkDeviceProvisioningEvent>
|
||||
fun startLinkDeviceProvisioning(allowLinkAndSync: Boolean): Flow<LinkDeviceProvisioningEvent>
|
||||
|
||||
/**
|
||||
* Performs the network call to register this device as a linked (secondary) device on a pre-existing
|
||||
|
||||
@@ -26,19 +26,25 @@ class RegistrationActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
private const val NEXT_INTENT_EXTRA = "next_intent"
|
||||
private const val START_DESTINATION_EXTRA = "start_destination"
|
||||
|
||||
/**
|
||||
* @param nextIntent An optional intent to launch once registration completes successfully. This is how the caller
|
||||
* (which lives outside this module) routes the user back into the main app, since the launching activity will
|
||||
* typically have finished itself.
|
||||
* @param startDestination An optional route to open directly instead of resuming a previous flow. Used, for example,
|
||||
* to send a deregistered linked device straight to the link-device screen.
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun createIntent(context: Context, nextIntent: Intent? = null): Intent {
|
||||
fun createIntent(context: Context, nextIntent: Intent? = null, startDestination: RegistrationRoute? = null): Intent {
|
||||
return Intent(context, RegistrationActivity::class.java).apply {
|
||||
if (nextIntent != null) {
|
||||
putExtra(NEXT_INTENT_EXTRA, nextIntent)
|
||||
}
|
||||
if (startDestination != null) {
|
||||
putExtra(START_DESTINATION_EXTRA, startDestination)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,11 +63,14 @@ class RegistrationActivity : ComponentActivity() {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val startDestination = IntentCompat.getParcelableExtra(intent, START_DESTINATION_EXTRA, RegistrationRoute::class.java)
|
||||
|
||||
setContent {
|
||||
SignalTheme(incognitoKeyboardEnabled = false) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
RegistrationNavHost(
|
||||
registrationRepository = repository,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding(),
|
||||
|
||||
+10
-7
@@ -126,7 +126,7 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
|
||||
data class AllowNotifications(val nextRoute: RegistrationRoute) : RegistrationRoute
|
||||
|
||||
@Serializable
|
||||
data object LinkAccount : RegistrationRoute
|
||||
data class LinkAccount(val showCreateAccount: Boolean = true) : RegistrationRoute
|
||||
|
||||
@Serializable
|
||||
data object MessageSync : RegistrationRoute
|
||||
@@ -262,6 +262,7 @@ private const val PIN_LEARN_MORE_URL = "https://support.signal.org/hc/articles/3
|
||||
* @param registrationRepository The repository for registration data.
|
||||
* @param registrationViewModel Optional ViewModel for testing. If null, creates one internally.
|
||||
* @param permissionsState Optional permissions state for testing. If null, creates one internally.
|
||||
* @param startDestination Optional route to open directly as the sole start destination, instead of showing [RegistrationRoute.Welcome] or restoring a previous flow.
|
||||
* @param modifier Modifier to be applied to the NavDisplay.
|
||||
* @param onRegistrationComplete Callback invoked when registration is successfully completed.
|
||||
*/
|
||||
@@ -271,11 +272,12 @@ fun RegistrationNavHost(
|
||||
registrationRepository: RegistrationRepository,
|
||||
registrationViewModel: RegistrationViewModel? = null,
|
||||
permissionsState: MultiplePermissionsState? = null,
|
||||
startDestination: RegistrationRoute? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
onRegistrationComplete: () -> Unit = {}
|
||||
) {
|
||||
val viewModel: RegistrationViewModel = registrationViewModel ?: viewModel(
|
||||
factory = RegistrationViewModel.Factory(registrationRepository)
|
||||
factory = RegistrationViewModel.Factory(registrationRepository, startDestination)
|
||||
)
|
||||
|
||||
val registrationState by viewModel.state.collectAsStateWithLifecycle()
|
||||
@@ -382,9 +384,9 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
|
||||
WelcomeScreenEvents.Continue -> navigateRequestingPermissions(RegistrationRoute.PhoneNumberEntry)
|
||||
WelcomeScreenEvents.LinkDevice -> {
|
||||
if (registrationViewModel.getRequiredLinkedDevicePermission().isNullOrBlank()) {
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.LinkAccount)
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.LinkAccount())
|
||||
} else {
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.AllowNotifications(RegistrationRoute.LinkAccount))
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.AllowNotifications(RegistrationRoute.LinkAccount()))
|
||||
}
|
||||
}
|
||||
WelcomeScreenEvents.HasOldPhone -> navigateRequestingPermissions(RegistrationRoute.QuickRestoreQrScan)
|
||||
@@ -430,17 +432,18 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
|
||||
}
|
||||
|
||||
// --- Link account Screen
|
||||
entry<RegistrationRoute.LinkAccount> {
|
||||
entry<RegistrationRoute.LinkAccount> { key ->
|
||||
val viewModel: LinkAccountViewModel = viewModel(
|
||||
factory = LinkAccountViewModel.Factory(
|
||||
repository = registrationRepository,
|
||||
parentState = registrationViewModel.state,
|
||||
parentEventEmitter = registrationViewModel::onEvent
|
||||
parentEventEmitter = registrationViewModel::onEvent,
|
||||
showCreateAccount = key.showCreateAccount
|
||||
)
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val url = stringResource(R.string.terms_and_privacy_policy_url) // TODO [regv5] update with proper url
|
||||
val url = "https://support.signal.org/hc/articles/360007320451-Troubleshooting-multiple-devices" // TODO [regv5] update with proper url
|
||||
|
||||
LinkAccountScreen(
|
||||
state = state,
|
||||
|
||||
+18
-2
@@ -294,8 +294,24 @@ class RegistrationRepository(val context: Context, val networkController: Networ
|
||||
* Starts a provisioning session for QR-based device linking.
|
||||
* See [NetworkController.startLinkDeviceProvisioning].
|
||||
*/
|
||||
fun startLinkDeviceProvisioning(): Flow<NetworkController.LinkDeviceProvisioningEvent> {
|
||||
return networkController.startLinkDeviceProvisioning()
|
||||
fun startLinkDeviceProvisioning(): Flow<NetworkController.LinkDeviceProvisioningEvent> = flow {
|
||||
emitAll(networkController.startLinkDeviceProvisioning(allowLinkAndSync = isCleanStart()))
|
||||
}
|
||||
|
||||
/**
|
||||
* True if this device is linked to a different account (different ACI) than the one advertised in
|
||||
* [message]. Returns false for a fresh (never-registered) device.
|
||||
*/
|
||||
suspend fun isProvisioningForDifferentAccount(message: NetworkController.LinkDeviceProvisioningMessage): Boolean {
|
||||
val previousAci = storageController.getPreExistingRegistrationData()?.aci ?: return false
|
||||
return previousAci != ACI.parseOrThrow(message.aci)
|
||||
}
|
||||
|
||||
/**
|
||||
* True if this device has no pre-existing registration (a fresh, never-registered device).
|
||||
*/
|
||||
suspend fun isCleanStart(): Boolean {
|
||||
return storageController.getPreExistingRegistrationData() == null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+26
-15
@@ -30,13 +30,20 @@ import kotlin.reflect.KClass
|
||||
* ViewModel shared across the registration flow.
|
||||
* Manages state and logic for registration screens.
|
||||
*/
|
||||
class RegistrationViewModel(private val repository: RegistrationRepository, savedStateHandle: SavedStateHandle) : EventDrivenViewModel<RegistrationFlowEvent>(TAG) {
|
||||
class RegistrationViewModel(
|
||||
private val repository: RegistrationRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
startDestination: RegistrationRoute? = null
|
||||
) : EventDrivenViewModel<RegistrationFlowEvent>(TAG) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(RegistrationViewModel::class)
|
||||
}
|
||||
|
||||
private var _state: MutableStateFlow<RegistrationFlowState> = savedStateHandle.getMutableStateFlow("registration_state", initialValue = RegistrationFlowState())
|
||||
private var _state: MutableStateFlow<RegistrationFlowState> = savedStateHandle.getMutableStateFlow(
|
||||
"registration_state",
|
||||
initialValue = RegistrationFlowState(backStack = listOf(startDestination ?: RegistrationRoute.Welcome))
|
||||
)
|
||||
val state: StateFlow<RegistrationFlowState> = _state.asStateFlow()
|
||||
|
||||
private val finishChannel = Channel<Unit>(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
@@ -45,17 +52,21 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save
|
||||
val resultBus = ResultEventBus()
|
||||
|
||||
init {
|
||||
_state.value = _state.value.copy(isRestoringNavigationState = true)
|
||||
viewModelScope.launch {
|
||||
val restored = repository.restoreFlowState()
|
||||
if (restored != null) {
|
||||
Log.i(TAG, "[init] Restored flow state from disk. Backstack size: ${restored.backStack.size}, hasSession: ${restored.sessionMetadata != null}")
|
||||
_state.value = validateRestoredState(restored).copy(isRestoringNavigationState = false)
|
||||
} else {
|
||||
_state.value = _state.value.copy(
|
||||
preExistingRegistrationData = repository.getPreExistingRegistrationData(),
|
||||
isRestoringNavigationState = false
|
||||
)
|
||||
if (startDestination != null) {
|
||||
_state.value = _state.value.copy(isRestoringNavigationState = false)
|
||||
} else {
|
||||
_state.value = _state.value.copy(isRestoringNavigationState = true)
|
||||
viewModelScope.launch {
|
||||
val restored = repository.restoreFlowState()
|
||||
if (restored != null) {
|
||||
Log.i(TAG, "[init] Restored flow state from disk. Backstack size: ${restored.backStack.size}, hasSession: ${restored.sessionMetadata != null}")
|
||||
_state.value = validateRestoredState(restored).copy(isRestoringNavigationState = false)
|
||||
} else {
|
||||
_state.value = _state.value.copy(
|
||||
preExistingRegistrationData = repository.getPreExistingRegistrationData(),
|
||||
isRestoringNavigationState = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,9 +214,9 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(private val repository: RegistrationRepository) : ViewModelProvider.Factory {
|
||||
class Factory(private val repository: RegistrationRepository, private val startDestination: RegistrationRoute? = null) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: KClass<T>, extras: CreationExtras): T {
|
||||
return RegistrationViewModel(repository, extras.createSavedStateHandle()) as T
|
||||
return RegistrationViewModel(repository, extras.createSavedStateHandle(), startDestination) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-12
@@ -62,6 +62,7 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import kotlinx.coroutines.delay
|
||||
import org.signal.core.ui.WindowBreakpoint
|
||||
import org.signal.core.ui.compose.AllDevicePreviews
|
||||
@@ -161,6 +162,19 @@ private fun StateDialogs(
|
||||
onDismiss = { onEvent(LinkAccountScreenEvent.DismissError) }
|
||||
)
|
||||
}
|
||||
|
||||
if (state.showDeleteDataDialog) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.LinkAccountScreen__delete_app_data_question),
|
||||
body = stringResource(R.string.LinkAccountScreen__you_are_attempting_to_link_a_different_account),
|
||||
confirm = stringResource(R.string.LinkAccountScreen__delete_and_restart),
|
||||
confirmColor = MaterialTheme.colorScheme.error,
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = { onEvent(LinkAccountScreenEvent.ConfirmDeleteAndRelink) },
|
||||
onDeny = { onEvent(LinkAccountScreenEvent.CancelDeleteAndRelink) },
|
||||
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -194,12 +208,16 @@ private fun OnePane(
|
||||
)
|
||||
}
|
||||
},
|
||||
footer = {
|
||||
OnePaneFooterContent(
|
||||
params = params,
|
||||
isElevated = scrollState.canScrollForward,
|
||||
onEvent = onEvent
|
||||
)
|
||||
footer = if (state.showCreateAccount) {
|
||||
{
|
||||
OnePaneFooterContent(
|
||||
params = params,
|
||||
isElevated = scrollState.canScrollForward,
|
||||
onEvent = onEvent
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -231,12 +249,16 @@ private fun TwoPane(
|
||||
expandButtonVisible = expandButtonVisible
|
||||
)
|
||||
},
|
||||
footer = {
|
||||
TwoPaneFooterContent(
|
||||
params = params,
|
||||
isElevated = false,
|
||||
onEvent = onEvent
|
||||
)
|
||||
footer = if (state.showCreateAccount) {
|
||||
{
|
||||
TwoPaneFooterContent(
|
||||
params = params,
|
||||
isElevated = false,
|
||||
onEvent = onEvent
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -678,6 +700,8 @@ private fun LinkAccountScreenPreview() {
|
||||
LinkAccountScreenEvent.HideOverlayClick -> displayQrOverlay = false
|
||||
LinkAccountScreenEvent.RetryQrCode -> Unit
|
||||
LinkAccountScreenEvent.DismissError -> Unit
|
||||
LinkAccountScreenEvent.ConfirmDeleteAndRelink -> Unit
|
||||
LinkAccountScreenEvent.CancelDeleteAndRelink -> Unit
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+9
-7
@@ -5,11 +5,13 @@
|
||||
|
||||
package org.signal.registration.screens.linkaccount
|
||||
|
||||
sealed class LinkAccountScreenEvent {
|
||||
data object GetHelpClick : LinkAccountScreenEvent()
|
||||
data object CreateAccountClick : LinkAccountScreenEvent()
|
||||
data object DisplayOverlayClick : LinkAccountScreenEvent()
|
||||
data object HideOverlayClick : LinkAccountScreenEvent()
|
||||
data object RetryQrCode : LinkAccountScreenEvent()
|
||||
data object DismissError : LinkAccountScreenEvent()
|
||||
sealed interface LinkAccountScreenEvent {
|
||||
data object GetHelpClick : LinkAccountScreenEvent
|
||||
data object CreateAccountClick : LinkAccountScreenEvent
|
||||
data object DisplayOverlayClick : LinkAccountScreenEvent
|
||||
data object HideOverlayClick : LinkAccountScreenEvent
|
||||
data object RetryQrCode : LinkAccountScreenEvent
|
||||
data object DismissError : LinkAccountScreenEvent
|
||||
data object ConfirmDeleteAndRelink : LinkAccountScreenEvent
|
||||
data object CancelDeleteAndRelink : LinkAccountScreenEvent
|
||||
}
|
||||
|
||||
+3
-1
@@ -12,5 +12,7 @@ data class LinkAccountScreenState(
|
||||
val displayQrOverlay: Boolean = false,
|
||||
val isRegistering: Boolean = false,
|
||||
val isWaitingForPrimary: Boolean = false,
|
||||
val showError: Boolean = false
|
||||
val showError: Boolean = false,
|
||||
val showDeleteDataDialog: Boolean = false,
|
||||
val showCreateAccount: Boolean = true
|
||||
)
|
||||
|
||||
+31
-8
@@ -37,7 +37,8 @@ import org.signal.registration.screens.util.navigateTo
|
||||
class LinkAccountViewModel(
|
||||
private val repository: RegistrationRepository,
|
||||
private val parentState: StateFlow<RegistrationFlowState>,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
|
||||
showCreateAccount: Boolean = true
|
||||
) : EventDrivenViewModel<LinkAccountScreenEvent>(TAG) {
|
||||
|
||||
companion object {
|
||||
@@ -45,10 +46,10 @@ class LinkAccountViewModel(
|
||||
private const val DEVICE_NAME = "Android"
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(LinkAccountScreenState())
|
||||
private val _state = MutableStateFlow(LinkAccountScreenState(showCreateAccount = showCreateAccount))
|
||||
val state: StateFlow<LinkAccountScreenState> = _state
|
||||
.onEach { Log.d(TAG, "[State] $it") }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LinkAccountScreenState())
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), _state.value)
|
||||
|
||||
private var provisioningJob: Job? = null
|
||||
|
||||
@@ -79,6 +80,14 @@ class LinkAccountViewModel(
|
||||
startProvisioning()
|
||||
state.copy(qrCodeState = QrState.Loading, showError = false)
|
||||
}
|
||||
LinkAccountScreenEvent.ConfirmDeleteAndRelink -> {
|
||||
viewModelScope.launch { repository.clearLocalDataAndRestart() }
|
||||
state.copy(showDeleteDataDialog = false)
|
||||
}
|
||||
LinkAccountScreenEvent.CancelDeleteAndRelink -> {
|
||||
startProvisioning()
|
||||
state.copy(qrCodeState = QrState.Loading, showDeleteDataDialog = false)
|
||||
}
|
||||
}
|
||||
stateEmitter(result)
|
||||
}
|
||||
@@ -108,12 +117,25 @@ class LinkAccountViewModel(
|
||||
}
|
||||
|
||||
private suspend fun handleProvisioningMessage(message: NetworkController.LinkDeviceProvisioningMessage) {
|
||||
if (repository.isProvisioningForDifferentAccount(message)) {
|
||||
Log.w(TAG, "[Register] Provisioning message is for a different account prompting to delete local data")
|
||||
_state.update { it.copy(isRegistering = false, showDeleteDataDialog = true) }
|
||||
return
|
||||
}
|
||||
|
||||
val isCleanStart = repository.isCleanStart()
|
||||
|
||||
_state.update { it.copy(isRegistering = true, qrCodeState = QrState.Scanned) }
|
||||
|
||||
when (val result = repository.registerAsLinkedDevice(message, DEVICE_NAME)) {
|
||||
is RequestResult.Success -> {
|
||||
Log.i(TAG, "[Register] Success! hasLinkAndSyncBackup: ${result.result.hasLinkAndSyncBackup}")
|
||||
if (result.result.hasLinkAndSyncBackup) {
|
||||
Log.i(TAG, "[Register] Success! hasLinkAndSyncBackup: ${result.result.hasLinkAndSyncBackup}, isCleanStart: $isCleanStart")
|
||||
|
||||
if (result.result.hasLinkAndSyncBackup && !isCleanStart) {
|
||||
Log.w(TAG, "[Register] Link-and-sync offered on a relink over existing data, skipping import")
|
||||
}
|
||||
|
||||
if (result.result.hasLinkAndSyncBackup && isCleanStart) {
|
||||
// Wait here until the primary actually makes the backup available or tells us not to expect one
|
||||
_state.update { it.copy(isRegistering = false, isWaitingForPrimary = true) }
|
||||
val waitResult = repository.awaitLinkAndSyncArchive()
|
||||
@@ -137,7 +159,7 @@ class LinkAccountViewModel(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No link-and-sync backup, restore from storage service immediately, then finish
|
||||
// No valid link-and-sync backup, restore from storage service immediately, then finish
|
||||
repository.restoreLinkedDeviceFromStorageService()
|
||||
_state.update { it.copy(isRegistering = false) }
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete)
|
||||
@@ -169,10 +191,11 @@ class LinkAccountViewModel(
|
||||
class Factory(
|
||||
private val repository: RegistrationRepository,
|
||||
private val parentState: StateFlow<RegistrationFlowState>,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
|
||||
private val showCreateAccount: Boolean = true
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return LinkAccountViewModel(repository, parentState, parentEventEmitter) as T
|
||||
return LinkAccountViewModel(repository, parentState, parentEventEmitter, showCreateAccount) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -36,9 +36,11 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import org.signal.core.ui.WindowBreakpoint
|
||||
import org.signal.core.ui.compose.AllDevicePreviews
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.rememberWindowBreakpoint
|
||||
@@ -68,6 +70,18 @@ fun MessageSyncScreen(
|
||||
is RegistrationScaffold.Params.TwoPane -> TwoPane(layoutParams, state, onEvent)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.showSyncFailedDialog) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.MessageSyncScreen__couldnt_restore_messages),
|
||||
body = stringResource(R.string.MessageSyncScreen__your_messages_couldnt_be_transferred),
|
||||
confirm = stringResource(R.string.MessageSyncScreen__try_again),
|
||||
onConfirm = { onEvent(MessageSyncScreenEvent.RetryClick) },
|
||||
dismiss = stringResource(R.string.MessageSyncScreen__continue_without_messages),
|
||||
onDeny = { onEvent(MessageSyncScreenEvent.ContinueWithoutMessagesClick) },
|
||||
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+2
@@ -8,4 +8,6 @@ package org.signal.registration.screens.messagesync
|
||||
sealed class MessageSyncScreenEvent {
|
||||
data object LearnMoreClick : MessageSyncScreenEvent()
|
||||
data object CancelClick : MessageSyncScreenEvent()
|
||||
data object RetryClick : MessageSyncScreenEvent()
|
||||
data object ContinueWithoutMessagesClick : MessageSyncScreenEvent()
|
||||
}
|
||||
|
||||
+2
-1
@@ -11,5 +11,6 @@ import org.signal.core.util.bytes
|
||||
data class MessageSyncScreenState(
|
||||
val downloadedBytes: ByteSize = 0.bytes,
|
||||
val totalBytes: ByteSize = 0.bytes,
|
||||
val isFinishing: Boolean = false
|
||||
val isFinishing: Boolean = false,
|
||||
val showSyncFailedDialog: Boolean = false
|
||||
)
|
||||
|
||||
+13
-2
@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
import org.signal.registration.RegistrationFlowState
|
||||
@@ -67,8 +68,8 @@ class MessageSyncViewModel(
|
||||
finish()
|
||||
}
|
||||
is LinkAndSyncProgress.Failed -> {
|
||||
Log.w(TAG, "[MessageSync] Link-and-sync failed; restoring from storage service then completing (still linked).", progress.cause)
|
||||
finish()
|
||||
Log.w(TAG, "[MessageSync] Link-and-sync failed; prompting the user to retry or continue without messages.", progress.cause)
|
||||
_state.update { it.copy(isFinishing = false, showSyncFailedDialog = true) }
|
||||
}
|
||||
is LinkAndSyncProgress.RelinkRequired -> {
|
||||
Log.w(TAG, "[MessageSync] Primary requested re-link; wiping local data and restarting.")
|
||||
@@ -108,6 +109,16 @@ class MessageSyncViewModel(
|
||||
finish(cancelDownload = true)
|
||||
state.copy(isFinishing = true)
|
||||
}
|
||||
MessageSyncScreenEvent.RetryClick -> {
|
||||
Log.i(TAG, "[MessageSync] User retrying link-and-sync after a failure.")
|
||||
startRestore()
|
||||
state.copy(showSyncFailedDialog = false, isFinishing = false, downloadedBytes = 0.bytes, totalBytes = 0.bytes)
|
||||
}
|
||||
MessageSyncScreenEvent.ContinueWithoutMessagesClick -> {
|
||||
Log.i(TAG, "[MessageSync] User continuing without message history after a failed link-and-sync.")
|
||||
finish()
|
||||
state.copy(showSyncFailedDialog = false, isFinishing = true)
|
||||
}
|
||||
}
|
||||
stateEmitter(result)
|
||||
}
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@ class PhoneNumberEntryViewModel(
|
||||
}
|
||||
}
|
||||
is PhoneNumberEntryScreenEvents.LinkDevice -> {
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.LinkAccount)
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.LinkAccount())
|
||||
}
|
||||
is PhoneNumberEntryScreenEvents.CaptchaCompleted -> {
|
||||
stateEmitter(applyCaptchaCompleted(state, event.token, parentEventEmitter))
|
||||
|
||||
@@ -407,6 +407,14 @@
|
||||
<string name="MessageSyncScreen__learn_more">Learn more</string>
|
||||
<!-- Button label that cancels the in-progress message sync -->
|
||||
<string name="MessageSyncScreen__cancel">Cancel</string>
|
||||
<!-- Title of the dialog shown when restoring messages from the other device fails -->
|
||||
<string name="MessageSyncScreen__couldnt_restore_messages">Couldn\'t restore messages</string>
|
||||
<!-- Body of the dialog shown when restoring messages fails, offering to retry or continue without message history -->
|
||||
<string name="MessageSyncScreen__your_messages_couldnt_be_transferred">Your messages couldn\'t be transferred from your other device. You can try again, or continue without your message history.</string>
|
||||
<!-- Button that retries the failed message restore -->
|
||||
<string name="MessageSyncScreen__try_again">Try again</string>
|
||||
<!-- Button that finishes linking without restoring message history after a failed restore -->
|
||||
<string name="MessageSyncScreen__continue_without_messages">Continue without messages</string>
|
||||
|
||||
<!-- Title for the screen that shows a QR code the user scans with their existing primary device to link this device as a secondary -->
|
||||
<string name="LinkAccountScreen__scan_this_code_to_link_your_account">Scan this code to link your account</string>
|
||||
@@ -436,6 +444,12 @@
|
||||
<string name="LinkAccountScreen__waiting_for_your_other_device">Waiting for your other device…</string>
|
||||
<!-- Error dialog message shown when linking this device fails -->
|
||||
<string name="LinkAccountScreen__error_linking_device">An error occurred while linking this device</string>
|
||||
<!-- Title of the confirmation dialog shown when the scanned QR code belongs to a different account than the one this device is currently linked to -->
|
||||
<string name="LinkAccountScreen__delete_app_data_question">Delete app data?</string>
|
||||
<!-- Body of the confirmation dialog warning that continuing will erase all local data before relinking to a different account -->
|
||||
<string name="LinkAccountScreen__you_are_attempting_to_link_a_different_account">You\'re attempting to link a different Signal account. If you continue, all Signal data currently on this device will be deleted.</string>
|
||||
<!-- Destructive confirmation button that deletes all local data and restarts the app so it can be linked to a different account -->
|
||||
<string name="LinkAccountScreen__delete_and_restart">Delete & restart</string>
|
||||
<!-- Content description for the button that closes the maximized QR code overlay -->
|
||||
<string name="LinkAccountScreen__close_qr_code">Close QR code</string>
|
||||
|
||||
|
||||
+3
-3
@@ -468,7 +468,7 @@ class RegistrationViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
val initialState = RegistrationFlowState(
|
||||
backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.PhoneNumberEntry, RegistrationRoute.LinkAccount)
|
||||
backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.PhoneNumberEntry, RegistrationRoute.LinkAccount())
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
@@ -494,7 +494,7 @@ class RegistrationViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
val initialState = RegistrationFlowState(
|
||||
backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.PhoneNumberEntry, RegistrationRoute.LinkAccount)
|
||||
backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.PhoneNumberEntry, RegistrationRoute.LinkAccount())
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, RegistrationFlowEvent.NavigateBackToScreen(RegistrationRoute.PhoneNumberEntry))
|
||||
@@ -511,7 +511,7 @@ class RegistrationViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
val initialState = RegistrationFlowState(
|
||||
backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.LinkAccount)
|
||||
backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.LinkAccount())
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, RegistrationFlowEvent.NavigateBackToScreen(RegistrationRoute.PhoneNumberEntry))
|
||||
|
||||
+53
-2
@@ -55,6 +55,7 @@ class LinkAccountViewModelTest {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
mockRepository = mockk(relaxed = true)
|
||||
every { mockRepository.startLinkDeviceProvisioning() } returns emptyFlow()
|
||||
coEvery { mockRepository.isCleanStart() } returns true
|
||||
emittedParentEvents = mutableListOf()
|
||||
parentEventEmitter = { event -> emittedParentEvents.add(event) }
|
||||
emittedStates = mutableListOf()
|
||||
@@ -131,6 +132,21 @@ class LinkAccountViewModelTest {
|
||||
assertThat(emittedParentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.MessageSync))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `link-and-sync offered on a relink over existing data skips import and restores from storage service`() = runTest(testDispatcher) {
|
||||
val flow = givenProvisioningFlow()
|
||||
val message = mockk<NetworkController.LinkDeviceProvisioningMessage>(relaxed = true)
|
||||
coEvery { mockRepository.isCleanStart() } returns false
|
||||
coEvery { mockRepository.registerAsLinkedDevice(message, any()) } returns RequestResult.Success(LinkedDeviceResult(hasLinkAndSyncBackup = true))
|
||||
|
||||
val viewModel = createViewModel()
|
||||
flow.emit(NetworkController.LinkDeviceProvisioningEvent.MessageReceived(message))
|
||||
|
||||
coVerify(exactly = 0) { mockRepository.awaitLinkAndSyncArchive() }
|
||||
coVerify { mockRepository.restoreLinkedDeviceFromStorageService() }
|
||||
assertThat(emittedParentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `link-and-sync offered but archive never arrives navigates to FullyComplete`() = runTest(testDispatcher) {
|
||||
val flow = givenProvisioningFlow()
|
||||
@@ -213,7 +229,7 @@ class LinkAccountViewModelTest {
|
||||
@Test
|
||||
fun `applyEvent CreateAccountClick from link-device-first flow routes through Permissions`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel(
|
||||
RegistrationFlowState(backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.LinkAccount))
|
||||
RegistrationFlowState(backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.LinkAccount()))
|
||||
)
|
||||
|
||||
viewModel.applyEvent(LinkAccountScreenState(), LinkAccountScreenEvent.CreateAccountClick, stateEmitter)
|
||||
@@ -234,7 +250,7 @@ class LinkAccountViewModelTest {
|
||||
RegistrationRoute.Welcome,
|
||||
RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry),
|
||||
RegistrationRoute.PhoneNumberEntry,
|
||||
RegistrationRoute.LinkAccount
|
||||
RegistrationRoute.LinkAccount()
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -246,6 +262,41 @@ class LinkAccountViewModelTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `provisioning message for a different account prompts to delete data without registering`() = runTest(testDispatcher) {
|
||||
val flow = givenProvisioningFlow()
|
||||
val message = mockk<NetworkController.LinkDeviceProvisioningMessage>(relaxed = true)
|
||||
coEvery { mockRepository.isProvisioningForDifferentAccount(message) } returns true
|
||||
|
||||
val viewModel = createViewModel()
|
||||
flow.emit(NetworkController.LinkDeviceProvisioningEvent.MessageReceived(message))
|
||||
|
||||
assertThat(viewModel.state.value.showDeleteDataDialog).isTrue()
|
||||
assertThat(viewModel.state.value.isRegistering).isFalse()
|
||||
coVerify(exactly = 0) { mockRepository.registerAsLinkedDevice(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyEvent ConfirmDeleteAndRelink wipes local data and dismisses the dialog`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.applyEvent(LinkAccountScreenState(showDeleteDataDialog = true), LinkAccountScreenEvent.ConfirmDeleteAndRelink, stateEmitter)
|
||||
|
||||
coVerify { mockRepository.clearLocalDataAndRestart() }
|
||||
assertThat(emittedStates.last().showDeleteDataDialog).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyEvent CancelDeleteAndRelink dismisses the dialog and restarts provisioning`() = runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.applyEvent(LinkAccountScreenState(showDeleteDataDialog = true), LinkAccountScreenEvent.CancelDeleteAndRelink, stateEmitter)
|
||||
|
||||
assertThat(emittedStates.last().qrCodeState).isEqualTo(QrState.Loading)
|
||||
assertThat(emittedStates.last().showDeleteDataDialog).isFalse()
|
||||
coVerify(exactly = 0) { mockRepository.clearLocalDataAndRestart() }
|
||||
}
|
||||
|
||||
private fun givenProvisioningFlow(): MutableSharedFlow<NetworkController.LinkDeviceProvisioningEvent> {
|
||||
val flow = MutableSharedFlow<NetworkController.LinkDeviceProvisioningEvent>(replay = 1)
|
||||
every { mockRepository.startLinkDeviceProvisioning() } returns flow
|
||||
|
||||
+29
-2
@@ -13,6 +13,7 @@ import assertk.assertions.isTrue
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -67,10 +68,36 @@ class MessageSyncViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore Failed still restores from storage service then navigates to FullyComplete`() = runTest(testDispatcher) {
|
||||
fun `restore Failed shows the retry dialog and does not finish registration`() = runTest(testDispatcher) {
|
||||
every { mockRepository.restoreLinkAndSyncBackup() } returns flowOf(LinkAndSyncProgress.Failed())
|
||||
|
||||
createViewModel()
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertThat(viewModel.state.value.showSyncFailedDialog).isTrue()
|
||||
assertThat(viewModel.state.value.isFinishing).isFalse()
|
||||
coVerify(exactly = 0) { mockRepository.restoreLinkedDeviceFromStorageService() }
|
||||
assertThat(emittedParentEvents).doesNotContain(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyEvent RetryClick clears the dialog and restarts the restore`() = runTest(testDispatcher) {
|
||||
every { mockRepository.restoreLinkAndSyncBackup() } returns flowOf(LinkAndSyncProgress.Failed())
|
||||
|
||||
val viewModel = createViewModel()
|
||||
var emitted: MessageSyncScreenState? = null
|
||||
viewModel.applyEvent(viewModel.state.value, MessageSyncScreenEvent.RetryClick) { emitted = it }
|
||||
|
||||
assertThat(emitted!!.showSyncFailedDialog).isFalse()
|
||||
// Once on init, once on retry.
|
||||
verify(exactly = 2) { mockRepository.restoreLinkAndSyncBackup() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyEvent ContinueWithoutMessagesClick restores from storage service then navigates to FullyComplete`() = runTest(testDispatcher) {
|
||||
every { mockRepository.restoreLinkAndSyncBackup() } returns flowOf(LinkAndSyncProgress.Failed())
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.applyEvent(viewModel.state.value, MessageSyncScreenEvent.ContinueWithoutMessagesClick) {}
|
||||
|
||||
coVerify { mockRepository.restoreLinkedDeviceFromStorageService() }
|
||||
assertThat(emittedParentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete))
|
||||
|
||||
+5
-5
@@ -215,8 +215,8 @@ class ProvisioningSocket<T> private constructor(
|
||||
|
||||
"/v1/message" -> {
|
||||
when (mode) {
|
||||
Mode.REREG -> provisioningMessageDeferral.complete(cipher.decrypt(RegistrationProvisionEnvelope.ADAPTER.decode(body)) as SecondaryProvisioningCipher.ProvisioningDecryptResult<T>)
|
||||
Mode.LINK -> provisioningMessageDeferral.complete(cipher.decrypt(ProvisionEnvelope.ADAPTER.decode(body)) as SecondaryProvisioningCipher.ProvisioningDecryptResult<T>)
|
||||
is Mode.Rereg -> provisioningMessageDeferral.complete(cipher.decrypt(RegistrationProvisionEnvelope.ADAPTER.decode(body)) as SecondaryProvisioningCipher.ProvisioningDecryptResult<T>)
|
||||
is Mode.Link -> provisioningMessageDeferral.complete(cipher.decrypt(ProvisionEnvelope.ADAPTER.decode(body)) as SecondaryProvisioningCipher.ProvisioningDecryptResult<T>)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,9 +291,9 @@ class ProvisioningSocket<T> private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
enum class Mode(val host: String, val params: String) {
|
||||
REREG("rereg", ""),
|
||||
LINK("linkdevice", "&capabilities=backup5")
|
||||
sealed class Mode(val host: String, val params: String) {
|
||||
data object Rereg : Mode("rereg", "")
|
||||
data class Link(val linkAndSyncCapable: Boolean) : Mode("linkdevice", if (linkAndSyncCapable) "&capabilities=backup5" else "")
|
||||
}
|
||||
|
||||
fun interface ProvisioningSocketExceptionHandler {
|
||||
|
||||
Reference in New Issue
Block a user