From 50884e144e79831a6c8a7465257bbee59725ed42 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 28 May 2026 15:33:24 -0300 Subject: [PATCH] Add linked device registration skeleton UI. --- .../securesms/ApplicationContext.java | 1 + .../settings/app/AppSettingsFragment.kt | 5 +- .../app/backups/BackupsSettingsFragment.kt | 2 +- .../preferences/RecipientPreference.kt | 2 +- .../requests/CallLinkIncomingRequestSheet.kt | 2 +- .../webrtc/v2/MoveableLocalVideoRenderer.kt | 3 +- .../contacts/paged/ContactSearchModels.kt | 2 +- .../AttachmentKeyboardButton.java | 2 +- .../ConversationListItem.java | 2 +- .../linkdevice/LinkDeviceFragment.kt | 3 +- .../megaphone/OnboardingMegaphone.kt | 2 +- .../recipients/ui/about/AboutSheet.kt | 2 +- .../ui/restore/NoBackupToRestoreFragment.kt | 3 +- .../securesms/util/Environment.kt | 1 + .../securesms/util/EnvironmentTest.kt | 5 + .../compose/LocalAnimatedVisibilityScope.kt | 14 + .../ui/compose/LocalSharedTransitionScope.kt | 14 + .../org/signal/core/ui/compose/SignalIcons.kt | 4 + .../res/drawable/symbol_device_phone_24.xml | 0 .../main/res/drawable/symbol_devices_24.xml | 0 .../main/res/drawable/symbol_maximize_24.xml | 0 .../res/drawable/symbol_person_circle_24.xml | 0 .../registration/sample/MainActivity.kt | 3 +- .../sample/RegistrationApplication.kt | 3 +- .../registration/RegistrationActivity.kt | 3 +- .../registration/RegistrationDependencies.kt | 1 + .../registration/RegistrationNavigation.kt | 93 ++- .../registration/RegistrationRepository.kt | 2 +- .../registration/RegistrationViewModel.kt | 8 + .../AllowNotificationsScreen.kt | 180 ++++++ .../screens/linkaccount/LinkAccountScreen.kt | 554 ++++++++++++++++++ .../linkaccount/LinkAccountScreenEvent.kt | 15 + .../linkaccount/LinkAccountScreenState.kt | 14 + .../linkaccount/LinkAccountViewModel.kt | 55 ++ .../screens/messagesync/MessageSyncScreen.kt | 286 +++++++++ .../messagesync/MessageSyncScreenEvent.kt | 13 + .../messagesync/MessageSyncScreenState.kt | 15 + .../messagesync/MessageSyncViewModel.kt | 50 ++ .../screens/welcome/WelcomeScreen.kt | 17 +- .../org/signal/registration/test/TestTags.kt | 17 + .../src/main/res/values/strings.xml | 44 ++ .../RegistrationNavigationTest.kt | 174 ++++++ .../linkaccount/LinkAccountScreenTest.kt | 111 ++++ .../screens/welcome/WelcomeScreenTest.kt | 5 +- 44 files changed, 1708 insertions(+), 24 deletions(-) create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/LocalAnimatedVisibilityScope.kt create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/LocalSharedTransitionScope.kt rename {app => core/ui}/src/main/res/drawable/symbol_device_phone_24.xml (100%) rename {app => core/ui}/src/main/res/drawable/symbol_devices_24.xml (100%) rename {app => core/ui}/src/main/res/drawable/symbol_maximize_24.xml (100%) rename {app => core/ui}/src/main/res/drawable/symbol_person_circle_24.xml (100%) create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/allownotifications/AllowNotificationsScreen.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreen.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenEvent.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenState.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountViewModel.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreen.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenEvent.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenState.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncViewModel.kt create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/linkaccount/LinkAccountScreenTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 7afc0acef2..a0c82c1e27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -420,6 +420,7 @@ public class ApplicationContext extends Application implements AppForegroundObse new org.signal.registration.RegistrationDependencies( new org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()), new org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController(this), + Environment.IS_LINK_AND_SYNC_AVAILABLE, null, context -> { context.startActivity(new Intent(context, SubmitDebugLogActivity.class)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt index 10ce9f399c..fdf1dd6194 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt @@ -81,6 +81,7 @@ import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.SignalE164Util import org.thoughtcrime.securesms.util.navigation.safeNavigate +import org.signal.core.ui.R as CoreUiR class AppSettingsFragment : ComposeFragment(), Callbacks { @@ -295,7 +296,7 @@ private fun AppSettingsContent( item { Rows.TextRow( text = stringResource(R.string.AccountSettingsFragment__account), - icon = painterResource(R.drawable.symbol_person_circle_24), + icon = painterResource(CoreUiR.drawable.symbol_person_circle_24), onClick = { callbacks.navigate(AppSettingsRoute.AccountRoute.Account) } @@ -305,7 +306,7 @@ private fun AppSettingsContent( item { Rows.TextRow( text = stringResource(R.string.preferences__linked_devices), - icon = painterResource(R.drawable.symbol_devices_24), + icon = painterResource(CoreUiR.drawable.symbol_devices_24), onClick = { callbacks.navigate(AppSettingsRoute.LinkDeviceRoute.LinkDevice) }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsFragment.kt index 5c55f8becc..cb263e7eef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsFragment.kt @@ -242,7 +242,7 @@ private fun BackupsSettingsContent( item { Rows.TextRow( text = stringResource(R.string.RemoteBackupsSettingsFragment__on_device_backups), - icon = ImageVector.vectorResource(R.drawable.symbol_device_phone_24), + icon = ImageVector.vectorResource(CoreUiR.drawable.symbol_device_phone_24), label = stringResource(R.string.RemoteBackupsSettingsFragment__save_your_backups_to), onClick = onOnDeviceBackupsRowClick ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/RecipientPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/RecipientPreference.kt index 53a1b2a0d8..d8d9923c7f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/RecipientPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/RecipientPreference.kt @@ -105,7 +105,7 @@ object RecipientPreference { } else { if (recipient.isSystemContact) { SpannableStringBuilder(recipient.getDisplayName(context)).apply { - val drawable = context.requireDrawable(R.drawable.symbol_person_circle_24).apply { + val drawable = context.requireDrawable(CoreUiR.drawable.symbol_person_circle_24).apply { setTint(ContextCompat.getColor(context, CoreUiR.color.signal_colorOnSurface)) } SpanUtil.appendCenteredImageSpan(this, drawable, 16, 16) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestSheet.kt index 61ceb64821..d6ef4a1287 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestSheet.kt @@ -221,7 +221,7 @@ private fun Title( style = MaterialTheme.typography.headlineMedium ) Icon( - painter = painterResource(id = R.drawable.symbol_person_circle_24), + painter = painterResource(id = CoreUiR.drawable.symbol_person_circle_24), contentDescription = null, modifier = Modifier .padding(start = 6.dp) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/MoveableLocalVideoRenderer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/MoveableLocalVideoRenderer.kt index 42b12e8a1d..a873e2244e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/MoveableLocalVideoRenderer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/MoveableLocalVideoRenderer.kt @@ -49,6 +49,7 @@ import org.signal.core.ui.compose.Previews import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState import org.thoughtcrime.securesms.events.CallParticipant +import org.signal.core.ui.R as CoreUiR /** * Small moveable local video renderer that displays the user's video in a draggable and expandable view. @@ -160,7 +161,7 @@ fun MoveableLocalVideoRenderer( ) { Icon( imageVector = ImageVector.vectorResource( - if (isFocused) R.drawable.symbol_minimize_24 else R.drawable.symbol_maximize_24 + if (isFocused) R.drawable.symbol_minimize_24 else CoreUiR.drawable.symbol_maximize_24 ), tint = MaterialTheme.colorScheme.onSecondaryContainer, contentDescription = stringResource( diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchModels.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchModels.kt index 5502533fb5..53c06ae8c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchModels.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchModels.kt @@ -643,7 +643,7 @@ object ContactSearchModels { val recipient = getRecipient(model) val suffix: CharSequence? = if (recipient.isSystemContact && !recipient.showVerified) { SpannableStringBuilder().apply { - val drawable = context.requireDrawable(R.drawable.symbol_person_circle_24).apply { + val drawable = context.requireDrawable(CoreUiR.drawable.symbol_person_circle_24).apply { setTint(ContextCompat.getColor(context, CoreUiR.color.signal_colorOnSurface)) } SpanUtil.appendCenteredImageSpan(this, drawable, 16, 16) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardButton.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardButton.java index 63bc50ff3c..10f85451bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardButton.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardButton.java @@ -10,7 +10,7 @@ public enum AttachmentKeyboardButton { GALLERY(R.string.AttachmentKeyboard_gallery, R.drawable.symbol_album_tilt_24), FILE(R.string.AttachmentKeyboard_file, R.drawable.symbol_file_24), PAYMENT(R.string.AttachmentKeyboard_payment, R.drawable.symbol_payment_24), - CONTACT(R.string.AttachmentKeyboard_contact, R.drawable.symbol_person_circle_24), + CONTACT(R.string.AttachmentKeyboard_contact, org.signal.core.ui.R.drawable.symbol_person_circle_24), LOCATION(R.string.AttachmentKeyboard_location, R.drawable.symbol_location_circle_24), POLL(R.string.AttachmentKeyboard_poll, R.drawable.symbol_poll_24); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java index 5f8647ae6b..64e3533552 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java @@ -245,7 +245,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind SpannableStringBuilder suffix = null; if (appendSystemContactIcon && recipient.get().isSystemContact() && !recipient.get().getShowVerified()) { suffix = new SpannableStringBuilder(); - Drawable drawable = ContextUtil.requireDrawable(getContext(), R.drawable.symbol_person_circle_24); + Drawable drawable = ContextUtil.requireDrawable(getContext(), org.signal.core.ui.R.drawable.symbol_person_circle_24); drawable.setTint(ContextCompat.getColor(getContext(), org.signal.core.ui.R.color.signal_colorOnSurface)); SpanUtil.appendCenteredImageSpan(suffix, drawable, 16, 16); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt index 570423a0df..3be12fcf06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt @@ -76,6 +76,7 @@ import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.SupportEmailUtil import org.thoughtcrime.securesms.util.navigation.safeNavigate +import org.signal.core.ui.R as CoreUiR private const val PLACEHOLDER = "__ICON_PLACEHOLDER__" @@ -503,7 +504,7 @@ fun DeviceRow(device: Device, isInternalUser: Boolean, setDeviceToRemove: (Devic .fillMaxWidth() ) { Image( - painter = painterResource(id = R.drawable.symbol_devices_24), + painter = painterResource(id = CoreUiR.drawable.symbol_devices_24), contentDescription = null, colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), contentScale = ContentScale.Inside, diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphone.kt b/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphone.kt index 9693525974..7bfa6677bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphone.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphone.kt @@ -222,7 +222,7 @@ enum class OnboardingListItem( ), ADD_PHOTO( title = R.string.Megaphones_add_a_profile_photo, - icon = R.drawable.symbol_person_circle_24, + icon = CoreUiR.drawable.symbol_person_circle_24, cardColor = R.color.onboarding_background_4 ), APPEARANCE( diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt index 89c2f63169..bd2017f6ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt @@ -312,7 +312,7 @@ private fun Content( if (!model.isSelf && model.systemContact) { AboutRow( - startIcon = ImageVector.vectorResource(id = R.drawable.symbol_person_circle_24), + startIcon = ImageVector.vectorResource(id = CoreUiR.drawable.symbol_person_circle_24), text = stringResource(id = R.string.AboutSheet__s_is_in_your_system_contacts, model.shortName), modifier = Modifier.fillMaxWidth() ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/NoBackupToRestoreFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/NoBackupToRestoreFragment.kt index 12a2f21d0c..16b8eaaef1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/NoBackupToRestoreFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/NoBackupToRestoreFragment.kt @@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.thoughtcrime.securesms.util.viewModel import kotlin.getValue +import org.signal.core.ui.R as CoreUiR /** * Shown when the old device is iOS and they are trying to transfer/restore on Android without a Signal Backup. @@ -137,7 +138,7 @@ private fun NoBackupToRestoreContent( verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.padding(horizontal = 32.dp) ) { - StepRow(icon = painterResource(R.drawable.symbol_device_phone_24), text = stringResource(id = R.string.NoBackupToRestore_step1)) + StepRow(icon = painterResource(CoreUiR.drawable.symbol_device_phone_24), text = stringResource(id = R.string.NoBackupToRestore_step1)) StepRow(icon = SignalIcons.Backup.painter, text = stringResource(id = R.string.NoBackupToRestore_step2)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt index dd849cc71f..2356210df5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt @@ -22,6 +22,7 @@ object Environment { } const val USE_NEW_REGISTRATION: Boolean = false + const val IS_LINK_AND_SYNC_AVAILABLE: Boolean = false object Backups { @JvmStatic diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/EnvironmentTest.kt b/app/src/test/java/org/thoughtcrime/securesms/util/EnvironmentTest.kt index 5b70c7141d..750a65d343 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/util/EnvironmentTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/util/EnvironmentTest.kt @@ -8,4 +8,9 @@ class EnvironmentTest { fun `USE_NEW_REGISTRATION must be false for release`() { assertFalse("USE_NEW_REGISTRATION must not be committed as true!", Environment.USE_NEW_REGISTRATION) } + + @Test + fun `IS_LINK_AND_SYNC_AVAILABLE must be false for release`() { + assertFalse("IS_LINK_AND_SYNC_AVAILABLE must not be committed as true!", Environment.IS_LINK_AND_SYNC_AVAILABLE) + } } diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/LocalAnimatedVisibilityScope.kt b/core/ui/src/main/java/org/signal/core/ui/compose/LocalAnimatedVisibilityScope.kt new file mode 100644 index 0000000000..d148c614e2 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/LocalAnimatedVisibilityScope.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.compose + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.runtime.compositionLocalOf + +/** + * Allows sharing the AnimatedVisibilityScope in deep hierarchies. Recommended by Android docs. + */ +val LocalAnimateVisibilityScope = compositionLocalOf { null } diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/LocalSharedTransitionScope.kt b/core/ui/src/main/java/org/signal/core/ui/compose/LocalSharedTransitionScope.kt new file mode 100644 index 0000000000..257d040a0a --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/LocalSharedTransitionScope.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.compose + +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.runtime.compositionLocalOf + +/** + * Allows sharing the SharedTransitionScope in deep hierarchies. Recommended by Android docs. + */ +val LocalSharedTransitionScope = compositionLocalOf { null } diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt index a3af36e447..b64ba6404b 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt @@ -47,6 +47,8 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon { CropLock(icon(R.drawable.symbol_crop_lock_24)), CropRotate(icon(R.drawable.symbol_crop_rotate_24)), CropUnlock(icon(R.drawable.symbol_crop_unlock_24)), + DevicePhone(icon(R.drawable.symbol_device_phone_24)), + Devices(icon(R.drawable.symbol_devices_24)), Draw(icon(R.drawable.symbol_draw_24)), Edit(icon(R.drawable.symbol_edit_24)), Emoji(icon(R.drawable.symbol_emoji_24)), @@ -61,7 +63,9 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon { Keyboard(icon(R.drawable.ic_keyboard_24)), Link(icon(R.drawable.symbol_link_24)), Lock(icon(R.drawable.symbol_lock_24)), + Maximize(icon(R.drawable.symbol_maximize_24)), MoreVertical(icon(R.drawable.symbol_more_vertical_24)), + PersonCircle(icon(R.drawable.symbol_person_circle_24)), Phone(icon(R.drawable.symbol_phone_24)), Plus(icon(R.drawable.symbol_plus_24)), QrCode(icon(R.drawable.symbol_qrcode_24)), diff --git a/app/src/main/res/drawable/symbol_device_phone_24.xml b/core/ui/src/main/res/drawable/symbol_device_phone_24.xml similarity index 100% rename from app/src/main/res/drawable/symbol_device_phone_24.xml rename to core/ui/src/main/res/drawable/symbol_device_phone_24.xml diff --git a/app/src/main/res/drawable/symbol_devices_24.xml b/core/ui/src/main/res/drawable/symbol_devices_24.xml similarity index 100% rename from app/src/main/res/drawable/symbol_devices_24.xml rename to core/ui/src/main/res/drawable/symbol_devices_24.xml diff --git a/app/src/main/res/drawable/symbol_maximize_24.xml b/core/ui/src/main/res/drawable/symbol_maximize_24.xml similarity index 100% rename from app/src/main/res/drawable/symbol_maximize_24.xml rename to core/ui/src/main/res/drawable/symbol_maximize_24.xml diff --git a/app/src/main/res/drawable/symbol_person_circle_24.xml b/core/ui/src/main/res/drawable/symbol_person_circle_24.xml similarity index 100% rename from app/src/main/res/drawable/symbol_person_circle_24.xml rename to core/ui/src/main/res/drawable/symbol_person_circle_24.xml diff --git a/demo/registration/src/main/java/org/signal/registration/sample/MainActivity.kt b/demo/registration/src/main/java/org/signal/registration/sample/MainActivity.kt index 86d23a7215..f85d57b18b 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/MainActivity.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/MainActivity.kt @@ -142,7 +142,8 @@ private fun SampleNavHost( RegistrationRepository( context = context.applicationContext, networkController = registrationDependencies.networkController, - storageController = registrationDependencies.storageController + storageController = registrationDependencies.storageController, + isLinkAndSyncAvailable = false ) } diff --git a/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt b/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt index 5167a10f7c..82c474fcee 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt @@ -60,7 +60,8 @@ class RegistrationApplication : Application() { networkController = networkController, storageController = storageController, sensitiveLogger = LogLogger, - debugLogCallback = {} + debugLogCallback = {}, + isLinkAndSyncAvailable = false ) ) diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationActivity.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationActivity.kt index 0d049e5075..b68c66ea71 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationActivity.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationActivity.kt @@ -29,7 +29,8 @@ class RegistrationActivity : ComponentActivity() { RegistrationRepository( context = this.application, networkController = RegistrationDependencies.get().networkController, - storageController = RegistrationDependencies.get().storageController + storageController = RegistrationDependencies.get().storageController, + isLinkAndSyncAvailable = RegistrationDependencies.get().isLinkAndSyncAvailable ) } diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationDependencies.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationDependencies.kt index 291d2e3e3a..1da0b8e64e 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationDependencies.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationDependencies.kt @@ -19,6 +19,7 @@ import org.signal.registration.util.SensitiveLog class RegistrationDependencies( val networkController: NetworkController, val storageController: StorageController, + val isLinkAndSyncAvailable: Boolean, val sensitiveLogger: Log.Logger?, val debugLogCallback: ((Context) -> Unit)? ) { diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index 21b476631b..e855c959a3 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -32,6 +32,7 @@ import androidx.navigation3.ui.NavDisplay import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.MultiplePermissionsState import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.accompanist.permissions.rememberPermissionState import kotlinx.parcelize.Parcelize import kotlinx.parcelize.TypeParceler import kotlinx.serialization.Serializable @@ -48,6 +49,7 @@ import org.signal.registration.screens.aepentry.EnterAepForLocalBackupViewModel import org.signal.registration.screens.aepentry.EnterAepForRemoteBackupPostRegistrationViewModel import org.signal.registration.screens.aepentry.EnterAepForRemoteBackupPreRegistrationViewModel import org.signal.registration.screens.aepentry.EnterAepScreen +import org.signal.registration.screens.allownotifications.AllowNotificationsScreen import org.signal.registration.screens.captcha.CaptchaScreen import org.signal.registration.screens.captcha.CaptchaScreenEvents import org.signal.registration.screens.captcha.CaptchaState @@ -55,11 +57,17 @@ import org.signal.registration.screens.countrycode.Country import org.signal.registration.screens.countrycode.CountryCodePickerRepository import org.signal.registration.screens.countrycode.CountryCodePickerScreen import org.signal.registration.screens.countrycode.CountryCodePickerViewModel +import org.signal.registration.screens.linkaccount.LinkAccountScreen +import org.signal.registration.screens.linkaccount.LinkAccountScreenEvent +import org.signal.registration.screens.linkaccount.LinkAccountViewModel import org.signal.registration.screens.localbackuprestore.EnterLocalBackupV1PassphaseScreen import org.signal.registration.screens.localbackuprestore.LocalBackupRestoreEvents import org.signal.registration.screens.localbackuprestore.LocalBackupRestoreResult import org.signal.registration.screens.localbackuprestore.LocalBackupRestoreScreen import org.signal.registration.screens.localbackuprestore.LocalBackupRestoreViewModel +import org.signal.registration.screens.messagesync.MessageSyncScreen +import org.signal.registration.screens.messagesync.MessageSyncScreenEvent +import org.signal.registration.screens.messagesync.MessageSyncViewModel import org.signal.registration.screens.permissions.PermissionsScreen import org.signal.registration.screens.phonenumber.PhoneNumberEntryScreenEvents import org.signal.registration.screens.phonenumber.PhoneNumberEntryViewModel @@ -98,6 +106,15 @@ sealed interface RegistrationRoute : NavKey, Parcelable { @Serializable data class Permissions(val nextRoute: RegistrationRoute) : RegistrationRoute + @Serializable + data class AllowNotifications(val nextRoute: RegistrationRoute) : RegistrationRoute + + @Serializable + data object LinkAccount : RegistrationRoute + + @Serializable + data object MessageSync : RegistrationRoute + @Serializable data object PhoneNumberEntry : RegistrationRoute @@ -316,11 +333,19 @@ private fun EntryProviderScope.navigationEntries( entry { val context = LocalContext.current val termsAndPrivacyUrl = stringResource(R.string.terms_and_privacy_policy_url) + WelcomeScreen( + isLinkAndSyncAvailable = registrationRepository.isLinkAndSyncAvailable, onEvent = { event -> when (event) { WelcomeScreenEvents.Continue -> parentEventEmitter.navigateTo(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry)) - WelcomeScreenEvents.LinkDevice -> throw NotImplementedError("Haven't implemented linked devices yet") + WelcomeScreenEvents.LinkDevice -> { + if (registrationViewModel.getRequiredLinkedDevicePermission().isNullOrBlank()) { + parentEventEmitter.navigateTo(RegistrationRoute.LinkAccount) + } else { + parentEventEmitter.navigateTo(RegistrationRoute.AllowNotifications(RegistrationRoute.LinkAccount)) + } + } WelcomeScreenEvents.HasOldPhone -> parentEventEmitter.navigateTo(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.QuickRestoreQrScan)) WelcomeScreenEvents.DoesNotHaveOldPhone -> parentEventEmitter.navigateTo(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.ArchiveRestoreSelection.forManualRestore())) WelcomeScreenEvents.ViewTermsAndPrivacy -> { @@ -348,6 +373,72 @@ private fun EntryProviderScope.navigationEntries( ) } + // --- Allow Notifications Screen + entry { key -> + val onProceed = { parentEventEmitter.navigateTo(key.nextRoute) } + val localPermissionState = rememberPermissionState( + permission = registrationViewModel.getRequiredLinkedDevicePermission()!!, + onPermissionResult = { onProceed() } + ) + + AllowNotificationsScreen( + permissionState = localPermissionState, + onProceed = onProceed + ) + } + + // --- Link account Screen + entry { + val viewModel: LinkAccountViewModel = viewModel( + factory = LinkAccountViewModel.Factory( + repository = registrationRepository, + parentState = registrationViewModel.state, + parentEventEmitter = registrationViewModel::onEvent + ) + ) + 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 + + LinkAccountScreen( + state = state, + onEvent = { + when (it) { + LinkAccountScreenEvent.GetHelpClick -> LinkActions.openUrl(context, url) { + Toast.makeText(context, R.string.LinkActions_error_no_browser_found, Toast.LENGTH_SHORT).show() + } + else -> viewModel.onEvent(it) + } + } + ) + } + + // --- Message Sync Screen + entry { + val viewModel: MessageSyncViewModel = viewModel( + factory = MessageSyncViewModel.Factory( + repository = registrationRepository, + parentState = registrationViewModel.state, + parentEventEmitter = registrationViewModel::onEvent + ) + ) + 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 + + MessageSyncScreen( + state = state, + onEvent = { + when (it) { + MessageSyncScreenEvent.LearnMoreClick -> LinkActions.openUrl(context, url) { + Toast.makeText(context, R.string.LinkActions_error_no_browser_found, Toast.LENGTH_SHORT).show() + } + else -> viewModel.onEvent(it) + } + } + ) + } + // -- Phone Number Entry Screen entry { val viewModel: PhoneNumberEntryViewModel = viewModel( diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt index a40baa8040..84aa0316f5 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -51,7 +51,7 @@ import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec -class RegistrationRepository(val context: Context, val networkController: NetworkController, val storageController: StorageController) { +class RegistrationRepository(val context: Context, val networkController: NetworkController, val storageController: StorageController, val isLinkAndSyncAvailable: Boolean) { companion object { private val TAG = Log.tag(RegistrationRepository::class) diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt index 167fffc243..2c8df39daa 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt @@ -164,6 +164,14 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save } } + fun getRequiredLinkedDevicePermission(): String? { + return if (Build.VERSION.SDK_INT >= 33) { + Manifest.permission.POST_NOTIFICATIONS + } else { + null + } + } + private suspend fun persistFlowState(event: RegistrationFlowEvent) { when (event) { is RegistrationFlowEvent.ResetState -> repository.clearFlowState() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/allownotifications/AllowNotificationsScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/allownotifications/AllowNotificationsScreen.kt new file mode 100644 index 0000000000..bce78acb72 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/allownotifications/AllowNotificationsScreen.kt @@ -0,0 +1,180 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.allownotifications + +import android.Manifest +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.isGranted +import org.signal.core.ui.compose.AllDevicePreviews +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.Previews +import org.signal.registration.R +import org.signal.registration.screens.OnePaneRegistrationScaffold +import org.signal.registration.screens.RegistrationScaffold +import org.signal.registration.screens.TwoPaneRegistrationScaffold +import org.signal.registration.screens.util.MockPermissionsState +import org.signal.registration.test.TestTags + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun AllowNotificationsScreen( + permissionState: PermissionState, + onProceed: () -> Unit, + modifier: Modifier = Modifier +) { + val layoutParams = RegistrationScaffold.rememberLayoutParams() + + Surface(modifier = modifier.testTag(TestTags.ALLOW_NOTIFICATIONS_SCREEN)) { + when (layoutParams) { + is RegistrationScaffold.Params.OnePane -> OnePane(layoutParams, permissionState, onProceed) + is RegistrationScaffold.Params.TwoPane -> TwoPane(layoutParams, permissionState, onProceed) + } + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun OnePane(params: RegistrationScaffold.Params.OnePane, permissionState: PermissionState, onProceed: () -> Unit) { + OnePaneRegistrationScaffold( + params = params, + footer = { + FooterContent( + permissionState = permissionState, + onProceed = onProceed + ) + } + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(it) + ) { + FirstPaneContent() + SecondPaneContent() + } + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun TwoPane(params: RegistrationScaffold.Params.TwoPane, permissionState: PermissionState, onProceed: () -> Unit) { + TwoPaneRegistrationScaffold( + params = params, + firstPane = { + FirstPaneContent( + modifier = Modifier + .padding(it) + .weight(1f) + ) + }, + secondPane = { + SecondPaneContent( + modifier = Modifier + .padding(it) + .weight(1f) + ) + }, + footer = { + FooterContent( + permissionState = permissionState, + onProceed = onProceed + ) + } + ) +} + +@Composable +private fun FirstPaneContent( + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Text( + text = stringResource(R.string.AllowNotificationsScreen__allow_notifications), + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.padding(bottom = 12.dp) + ) + + Text( + text = stringResource(R.string.AllowNotificationsScreen__signal_would_like_to_request_the_notification_permission), + style = MaterialTheme.typography.titleMedium + ) + } +} + +@Composable +private fun SecondPaneContent( + modifier: Modifier = Modifier +) { + // TODO [regv5] Final image asset + Image( + painter = painterResource(R.drawable.welcome), + contentDescription = null, + modifier = modifier + ) +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun FooterContent( + permissionState: PermissionState, + onProceed: () -> Unit +) { + Row( + horizontalArrangement = spacedBy(56.dp), + modifier = Modifier.padding(56.dp) + ) { + Spacer(modifier = Modifier.weight(1f)) + + TextButton( + onClick = onProceed, + modifier = Modifier.testTag(TestTags.ALLOW_NOTIFICATIONS_NOT_NOW_BUTTON) + ) { + Text(text = stringResource(R.string.AllowNotificationsScreen__not_now)) + } + + Buttons.LargeTonal( + onClick = { + if (permissionState.status.isGranted) { + onProceed() + } else { + permissionState.launchPermissionRequest() + } + }, + modifier = Modifier.testTag(TestTags.ALLOW_NOTIFICATIONS_NEXT_BUTTON) + ) { + Text(text = stringResource(R.string.AllowNotificationsScreen__next)) + } + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@AllDevicePreviews +@Composable +private fun AllowNotificationsScreenPreview() { + Previews.Preview { + AllowNotificationsScreen( + permissionState = MockPermissionsState(permission = Manifest.permission.POST_NOTIFICATIONS), + onProceed = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreen.kt new file mode 100644 index 0000000000..d2158fd972 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreen.kt @@ -0,0 +1,554 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.linkaccount + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.spacedBy +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +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.CompositionLocalProvider +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +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 org.signal.core.ui.WindowBreakpoint +import org.signal.core.ui.compose.AllDevicePreviews +import org.signal.core.ui.compose.IconButtons +import org.signal.core.ui.compose.LocalAnimateVisibilityScope +import org.signal.core.ui.compose.LocalSharedTransitionScope +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.QrCode +import org.signal.core.ui.compose.QrCodeData +import org.signal.core.ui.compose.SignalIcons +import org.signal.core.ui.rememberWindowBreakpoint +import org.signal.registration.R +import org.signal.registration.screens.OnePaneRegistrationScaffold +import org.signal.registration.screens.RegistrationScaffold +import org.signal.registration.screens.TwoPaneRegistrationScaffold +import org.signal.registration.screens.quickrestore.QrState +import org.signal.registration.test.TestTags + +/** + * Screen which will display a QR code for linking this device as a secondary. + */ +@Composable +fun LinkAccountScreen( + state: LinkAccountScreenState, + onEvent: (LinkAccountScreenEvent) -> Unit, + modifier: Modifier = Modifier +) { + val layoutParams = RegistrationScaffold.rememberLayoutParams() + + Surface(modifier = modifier.testTag(TestTags.LINK_ACCOUNT_SCREEN)) { + SharedTransitionLayout { + AnimatedContent( + targetState = state.displayQrOverlay, + label = "qr_code_fullscreen_transition" + ) { target -> + CompositionLocalProvider( + LocalSharedTransitionScope provides this@SharedTransitionLayout, + LocalAnimateVisibilityScope provides this + ) { + if (target) { + QrCodeOverlay(state, onEvent) + } else { + when (layoutParams) { + is RegistrationScaffold.Params.OnePane -> OnePane(layoutParams, state, onEvent) + is RegistrationScaffold.Params.TwoPane -> TwoPane(layoutParams, state, onEvent) + } + } + } + } + } + } +} + +@Composable +private fun OnePane( + params: RegistrationScaffold.Params.OnePane, + state: LinkAccountScreenState, + onEvent: (LinkAccountScreenEvent) -> Unit +) { + OnePaneRegistrationScaffold( + params = params, + footer = { OnePaneFooterContent(onEvent = onEvent) } + ) { + val scroller = rememberScrollState() + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = spacedBy(64.dp), + modifier = Modifier + .padding(it) + .verticalScroll(scroller) + ) { + Title() + + QrCodeContent(state = state, onEvent = onEvent) + + Steps(verticalArrangement = spacedBy(32.dp), onEvent) + } + } +} + +@Composable +private fun TwoPane( + params: RegistrationScaffold.Params.TwoPane, + state: LinkAccountScreenState, + onEvent: (LinkAccountScreenEvent) -> Unit +) { + TwoPaneRegistrationScaffold( + params = params, + firstPane = { + FirstPaneContent( + onEvent = onEvent, + modifier = Modifier + .padding(it) + .weight(1f) + ) + }, + secondPane = { + QrCodeContent( + state = state, + onEvent = onEvent, + modifier = Modifier + .weight(1f) + .padding(it) + ) + }, + footer = { TwoPaneFooterContent(onEvent = onEvent) } + ) +} + +@Composable +private fun FirstPaneContent( + onEvent: (LinkAccountScreenEvent) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + verticalArrangement = spacedBy(32.dp) + ) { + Title() + + Steps(verticalArrangement = spacedBy(32.dp), onEvent = onEvent) + } +} + +@Composable +private fun Title() { + Text( + text = stringResource(R.string.LinkAccountScreen__scan_this_code_to_link_your_account), + style = MaterialTheme.typography.headlineLarge + ) +} + +@Composable +private fun Steps( + verticalArrangement: Arrangement.Vertical, + onEvent: (LinkAccountScreenEvent) -> Unit +) { + Column(verticalArrangement = verticalArrangement) { + Step( + icon = SignalIcons.DevicePhone.imageVector, + text = stringResource(R.string.LinkAccountScreen__open_signal_on_your_phone) + ) + + Step( + icon = SignalIcons.PersonCircle.imageVector, + text = stringResource(R.string.LinkAccountScreen__tap_your_profile_picture_to_open_signal_settings) + ) + + Step( + icon = SignalIcons.Devices.imageVector, + text = stringResource(R.string.LinkAccountScreen__tap_linked_devices_and_link_new_device) + ) + + Step( + icon = SignalIcons.QrCode.imageVector, + text = stringResource(R.string.LinkAccountScreen__position_the_camera_over_this_qr_code) + ) + + GetHelp(onEvent) + } +} + +@Composable +private fun GetHelp( + onEvent: (LinkAccountScreenEvent) -> Unit +) { + TextButton( + onClick = { onEvent(LinkAccountScreenEvent.GetHelpClick) }, + modifier = Modifier.testTag(TestTags.LINK_ACCOUNT_GET_HELP_BUTTON) + ) { + Text(text = stringResource(R.string.LinkAccountScreen__get_help_with_these_steps)) + } +} + +@Composable +private fun Step(icon: ImageVector, text: String) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) { + Row( + horizontalArrangement = spacedBy(24.dp) + ) { + Icon(imageVector = icon, contentDescription = null) + Text(text = text) + } + } +} + +@Composable +private fun QrCodeContent( + state: LinkAccountScreenState, + onEvent: (LinkAccountScreenEvent) -> Unit, + modifier: Modifier = Modifier +) { + val sharedTransitionScope = LocalSharedTransitionScope.current!! + val animatedVisibilityScope = LocalAnimateVisibilityScope.current!! + + Box( + contentAlignment = if (!state.displayQrOverlay) { + Alignment.CenterEnd + } else Alignment.Center, + modifier = modifier + ) { + with(sharedTransitionScope) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .sharedElement( + sharedContentState = rememberSharedContentState("qr_code_outer_border"), + animatedVisibilityScope = animatedVisibilityScope + ) + .size(getQrOuterBorderSize(state.displayQrOverlay)) + .background(color = colorResource(org.signal.core.ui.R.color.signal_light_colorPrimary), shape = RoundedCornerShape(64.dp)) + ) { + AnimatedContent( + targetState = state.qrCodeState, + modifier = Modifier + .sharedElement( + sharedContentState = rememberSharedContentState("qr_code_inner_border"), + animatedVisibilityScope = animatedVisibilityScope + ) + .size(getQrInnerBorderSize(state.displayQrOverlay)) + .background(color = Color.White, shape = RoundedCornerShape(24.dp)) + ) { target -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + when (target) { + QrState.Failed -> QrCodeFailed() + is QrState.Loaded -> QrCodeDisplay(target.qrCodeData, state.displayQrOverlay, sharedTransitionScope, animatedVisibilityScope) + QrState.Loading -> QrCodeLoading() + QrState.Scanned -> QrCodeScanned() + } + } + } + } + } + + AnimatedVisibility( + visible = state.qrCodeState is QrState.Loaded && !state.displayQrOverlay, + modifier = Modifier.align(Alignment.TopEnd), + enter = fadeIn(), + exit = fadeOut() + ) { + IconButtons.IconButton( + onClick = { onEvent(LinkAccountScreenEvent.DisplayOverlayClick) }, + size = 53.dp, + colors = IconButtons.iconButtonColors( + containerColor = Color(0xFF506DCD), + contentColor = colorResource(org.signal.core.ui.R.color.signal_light_colorOnPrimary) + ), + modifier = Modifier.testTag(TestTags.LINK_ACCOUNT_DISPLAY_OVERLAY_BUTTON) + ) { + Icon( + imageVector = SignalIcons.Maximize.imageVector, + contentDescription = stringResource(R.string.LinkAccountScreen__maximize_qr_code) + ) + } + } + } +} + +@Composable +private fun QrCodeDisplay( + qrCodeData: QrCodeData, + isInOverlay: Boolean, + sharedTransitionScope: SharedTransitionScope, + animatedVisibilityScope: AnimatedVisibilityScope +) { + with(sharedTransitionScope) { + QrCode( + data = qrCodeData, + foregroundColor = colorResource(org.signal.core.ui.R.color.signal_light_colorPrimary), + modifier = Modifier + .sharedElement( + sharedContentState = rememberSharedContentState("qr_code_display"), + animatedVisibilityScope = animatedVisibilityScope + ) + .size(getQrCodeSize(isInOverlay)) + ) + } +} + +@Composable +private fun QrCodeLoading() { + CircularProgressIndicator( + color = colorResource(org.signal.core.ui.R.color.signal_light_colorPrimary), + modifier = Modifier.size(48.dp) + ) +} + +@Composable +private fun QrCodeScanned() { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = spacedBy(8.dp) + ) { + Icon( + imageVector = SignalIcons.CheckCircle.imageVector, + contentDescription = null, + tint = colorResource(org.signal.core.ui.R.color.signal_light_colorPrimary), + modifier = Modifier.size(48.dp) + ) + Text( + text = stringResource(R.string.LinkAccountScreen__scanned), + style = MaterialTheme.typography.bodyMedium, + color = colorResource(org.signal.core.ui.R.color.signal_light_colorOnSurfaceVariant) + ) + } +} + +@Composable +private fun QrCodeFailed() { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = spacedBy(8.dp) + ) { + Icon( + imageVector = SignalIcons.ErrorCircle.imageVector, + contentDescription = null, + tint = colorResource(org.signal.core.ui.R.color.signal_light_colorError), + modifier = Modifier.size(48.dp) + ) + Text( + text = stringResource(R.string.LinkAccountScreen__failed_to_generate_code), + style = MaterialTheme.typography.bodyMedium, + color = colorResource(org.signal.core.ui.R.color.signal_light_colorError) + ) + } +} + +@Composable +fun QrCodeOverlay( + state: LinkAccountScreenState, + onEvent: (LinkAccountScreenEvent) -> Unit +) { + Surface( + modifier = Modifier.fillMaxSize() + ) { + Box { + QrCodeContent( + state = state, + onEvent = onEvent, + modifier = Modifier.align(Alignment.Center) + ) + + IconButtons.IconButton( + onClick = { onEvent(LinkAccountScreenEvent.HideOverlayClick) }, + modifier = Modifier.testTag(TestTags.LINK_ACCOUNT_HIDE_OVERLAY_BUTTON) + ) { + Icon(imageVector = SignalIcons.X.imageVector, contentDescription = null) // TODO 'close' + } + } + } +} + +@Composable +fun getQrOuterBorderSize(isInOverlay: Boolean): Dp { + if (isInOverlay) { + return 456.dp + } + + val breakpoint = rememberWindowBreakpoint() + return when (breakpoint) { + WindowBreakpoint.SMALL -> 296.dp + WindowBreakpoint.MEDIUM -> 296.dp + WindowBreakpoint.LARGE_WIDTH -> 364.dp + WindowBreakpoint.LARGE_HEIGHT -> 364.dp + } +} + +@Composable +fun getQrInnerBorderSize(isInOverlay: Boolean): Dp { + if (isInOverlay) { + return 360.dp + } + + val breakpoint = rememberWindowBreakpoint() + return when (breakpoint) { + WindowBreakpoint.SMALL -> 232.dp + WindowBreakpoint.MEDIUM -> 232.dp + WindowBreakpoint.LARGE_WIDTH -> 284.dp + WindowBreakpoint.LARGE_HEIGHT -> 284.dp + } +} + +@Composable +fun getQrCodeSize(isInOverlay: Boolean): Dp { + if (isInOverlay) { + return 297.dp + } + + val breakpoint = rememberWindowBreakpoint() + return when (breakpoint) { + WindowBreakpoint.SMALL -> 208.dp + WindowBreakpoint.MEDIUM -> 208.dp + WindowBreakpoint.LARGE_WIDTH -> 256.dp + WindowBreakpoint.LARGE_HEIGHT -> 256.dp + } +} + +@Composable +private fun OnePaneFooterContent( + onEvent: (LinkAccountScreenEvent) -> Unit, + modifier: Modifier = Modifier +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .padding(start = 36.dp, end = 36.dp, bottom = 16.dp) + .fillMaxWidth() + ) { + Row { + DontHaveSignal() + } + CreateAccount(onEvent) + } +} + +@Composable +private fun TwoPaneFooterContent( + onEvent: (LinkAccountScreenEvent) -> Unit, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = spacedBy(8.dp), + modifier = modifier.padding(36.dp) + ) { + Spacer(modifier = Modifier.weight(1f)) + + DontHaveSignal() + CreateAccount(onEvent) + + Spacer(modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun DontHaveSignal() { + Icon( + imageVector = SignalIcons.DevicePhone.imageVector, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + contentDescription = null + ) + + Text( + text = stringResource(R.string.LinkAccountScreen__dont_have_signal_on_another_device), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + +@Composable +private fun CreateAccount(onEvent: (LinkAccountScreenEvent) -> Unit) { + Text( + text = buildAnnotatedString { + withLink( + LinkAnnotation.Clickable( + tag = "create-account", + styles = TextLinkStyles( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + textDecoration = TextDecoration.Underline + ) + ), + linkInteractionListener = { + onEvent(LinkAccountScreenEvent.CreateAccountClick) + } + ) + ) { + append(stringResource(R.string.LinkAccountScreen__create_account)) + } + }, + modifier = Modifier.testTag(TestTags.LINK_ACCOUNT_CREATE_ACCOUNT_LINK) + ) +} + +@AllDevicePreviews +@Composable +private fun LinkAccountScreenPreview() { + var displayQrOverlay by remember { mutableStateOf(false) } + + Previews.Preview { + LinkAccountScreen( + state = LinkAccountScreenState( + qrCodeState = QrState.Loaded(qrCodeData = QrCodeData.forData("sgnl://rereg?uuid=test&pub_key=test", true)), + displayQrOverlay = displayQrOverlay + ), + onEvent = { + when (it) { + LinkAccountScreenEvent.CreateAccountClick -> Unit + LinkAccountScreenEvent.DisplayOverlayClick -> displayQrOverlay = true + LinkAccountScreenEvent.GetHelpClick -> Unit + LinkAccountScreenEvent.HideOverlayClick -> displayQrOverlay = false + } + } + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenEvent.kt b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenEvent.kt new file mode 100644 index 0000000000..450d7aff23 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenEvent.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.linkaccount + +import org.signal.registration.util.DebugLoggableModel + +sealed class LinkAccountScreenEvent : DebugLoggableModel() { + data object GetHelpClick : LinkAccountScreenEvent() + data object CreateAccountClick : LinkAccountScreenEvent() + data object DisplayOverlayClick : LinkAccountScreenEvent() + data object HideOverlayClick : LinkAccountScreenEvent() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenState.kt b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenState.kt new file mode 100644 index 0000000000..8c3e10877e --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenState.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.linkaccount + +import org.signal.registration.screens.quickrestore.QrState +import org.signal.registration.util.DebugLoggableModel + +data class LinkAccountScreenState( + val qrCodeState: QrState = QrState.Loading, + val displayQrOverlay: Boolean = false +) : DebugLoggableModel() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountViewModel.kt new file mode 100644 index 0000000000..c3c3d35e43 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountViewModel.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.linkaccount + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import org.signal.core.util.logging.Log +import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationFlowState +import org.signal.registration.RegistrationRepository +import org.signal.registration.RegistrationRoute +import org.signal.registration.screens.EventDrivenViewModel +import org.signal.registration.screens.util.navigateTo + +class LinkAccountViewModel( + private val repository: RegistrationRepository, + private val parentState: StateFlow, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(LinkAccountViewModel::class) + } + + private val _state = MutableStateFlow(LinkAccountScreenState()) + val state: StateFlow = _state.asStateFlow() + + // TODO [regv5] - load qr code data + + override suspend fun processEvent(event: LinkAccountScreenEvent) { + when (event) { + LinkAccountScreenEvent.GetHelpClick -> error("This event is handled in the nav-entry.") + LinkAccountScreenEvent.CreateAccountClick -> parentEventEmitter.navigateTo(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry)) + LinkAccountScreenEvent.DisplayOverlayClick -> _state.update { it.copy(displayQrOverlay = true) } + LinkAccountScreenEvent.HideOverlayClick -> _state.update { it.copy(displayQrOverlay = false) } + } + } + + class Factory( + private val repository: RegistrationRepository, + private val parentState: StateFlow, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return LinkAccountViewModel(repository, parentState, parentEventEmitter) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreen.kt new file mode 100644 index 0000000000..a5f53f114a --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreen.kt @@ -0,0 +1,286 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.messagesync + +import androidx.compose.foundation.Image +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.widthIn +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +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.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +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.Previews +import org.signal.core.ui.compose.SignalIcons +import org.signal.core.ui.rememberWindowBreakpoint +import org.signal.core.util.kibiBytes +import org.signal.core.util.mebiBytes +import org.signal.registration.R +import org.signal.registration.screens.OnePaneRegistrationScaffold +import org.signal.registration.screens.RegistrationScaffold +import org.signal.registration.screens.TwoPaneRegistrationScaffold +import org.signal.registration.test.TestTags + +/** + * Displayed while we are syncing messages from the primary device. + */ +@Composable +fun MessageSyncScreen( + state: MessageSyncScreenState, + onEvent: (MessageSyncScreenEvent) -> Unit, + modifier: Modifier = Modifier +) { + val layoutParams = RegistrationScaffold.rememberLayoutParams() + + Surface(modifier = modifier.testTag(TestTags.MESSAGE_SYNC_SCREEN)) { + when (layoutParams) { + is RegistrationScaffold.Params.OnePane -> OnePane(layoutParams, state, onEvent) + is RegistrationScaffold.Params.TwoPane -> TwoPane(layoutParams, state, onEvent) + } + } +} + +@Composable +private fun OnePane(params: RegistrationScaffold.Params.OnePane, state: MessageSyncScreenState, onEvent: (MessageSyncScreenEvent) -> Unit) { + OnePaneRegistrationScaffold( + params = params, + footer = { FooterContent(params = params, onEvent = onEvent) } + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(it) + ) { + FirstPaneContent(state) + SecondPaneContent() + } + } +} + +@Composable +private fun TwoPane(params: RegistrationScaffold.Params.TwoPane, state: MessageSyncScreenState, onEvent: (MessageSyncScreenEvent) -> Unit) { + TwoPaneRegistrationScaffold( + params = params, + firstPane = { + FirstPaneContent( + state = state, + modifier = Modifier + .padding(it) + .weight(1f) + ) + }, + secondPane = { + SecondPaneContent( + modifier = Modifier + .padding(it) + .weight(1f) + ) + }, + footer = { FooterContent(params = params, onEvent = onEvent) } + ) +} + +@Composable +private fun FirstPaneContent( + state: MessageSyncScreenState, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Text( + text = stringResource(R.string.MessageSyncScreen__syncing_messages), + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.padding(bottom = 12.dp) + ) + + Text( + text = stringResource(R.string.MessageSyncScreen__this_may_take_a_few_minutes), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 47.dp) + ) + + LinearProgressIndicator( + progress = { + if (state.totalBytes.bytes > 0) { + state.downloadedBytes.bytes.toFloat() / state.totalBytes.bytes.toFloat() + } else { + 0f + } + }, + drawStopIndicator = {}, + gapSize = 0.dp, + modifier = Modifier + .padding(bottom = 16.dp) + .widthIn(max = 415.dp) + .fillMaxWidth() + ) + + Text( + text = stringResource( + R.string.MessageSyncScreen__downloading_s_of_s, + state.downloadedBytes.toUnitString(), + state.totalBytes.toUnitString() + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun SecondPaneContent( + modifier: Modifier = Modifier +) { + // TODO [regv5] Final image asset + Image( + painter = painterResource(R.drawable.welcome), + contentDescription = null, + modifier = modifier + ) +} + +@Composable +private fun FooterContent( + params: RegistrationScaffold.Params, + onEvent: (MessageSyncScreenEvent) -> Unit, + modifier: Modifier = Modifier +) { + val breakpoint = rememberWindowBreakpoint() + + when (breakpoint) { + WindowBreakpoint.SMALL -> StackedFooter(params, modifier, onEvent) + WindowBreakpoint.MEDIUM -> StackedFooter(params, modifier, onEvent) + WindowBreakpoint.LARGE_WIDTH -> InlineFooter(modifier, onEvent) + WindowBreakpoint.LARGE_HEIGHT -> InlineFooter(modifier, onEvent) + } +} + +@Composable +private fun StackedFooter(params: RegistrationScaffold.Params, modifier: Modifier, onEvent: (MessageSyncScreenEvent) -> Unit) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Notice( + onEvent = onEvent, + modifier = Modifier + .padding(horizontal = 58.dp) + .padding(bottom = 16.dp) + ) + Cancel( + onEvent = onEvent, + modifier = Modifier + .padding(horizontal = 56.dp) + .padding(bottom = 36.dp) + .widthIn(max = params.maxButtonWidth) + .fillMaxWidth() + ) + } +} + +@Composable +private fun InlineFooter(modifier: Modifier, onEvent: (MessageSyncScreenEvent) -> Unit) { + Box(modifier = modifier) { + Notice( + onEvent = onEvent, + modifier = Modifier + .padding(horizontal = 58.dp, vertical = 36.dp) + ) + Cancel( + onEvent = onEvent, + modifier = Modifier + .padding(horizontal = 56.dp, vertical = 36.dp) + .align(Alignment.CenterEnd) + ) + } +} + +@Composable +private fun Notice(modifier: Modifier, onEvent: (MessageSyncScreenEvent) -> Unit) { + Row(modifier = modifier) { + Spacer(modifier = Modifier.weight(1f)) + + Icon( + imageVector = SignalIcons.Lock.imageVector, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = buildAnnotatedString { + append(stringResource(R.string.MessageSyncScreen__messages_and_chat_info_are_protected_by_e2ee)) + append(' ') + + withLink( + LinkAnnotation.Clickable( + tag = "learn-more", + styles = TextLinkStyles( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + textDecoration = TextDecoration.Underline + ) + ), + linkInteractionListener = { + onEvent(MessageSyncScreenEvent.LearnMoreClick) + } + ) + ) { + append(stringResource(R.string.MessageSyncScreen__learn_more)) + } + }, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(TestTags.MESSAGE_SYNC_LEARN_MORE_LINK).widthIn(max = 405.dp) + ) + + Spacer(modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun Cancel(onEvent: (MessageSyncScreenEvent) -> Unit, modifier: Modifier = Modifier) { + Buttons.LargeTonal( + onClick = { onEvent(MessageSyncScreenEvent.CancelClick) }, + modifier = modifier.testTag(TestTags.MESSAGE_SYNC_CANCEL_BUTTON) + ) { + Text(text = stringResource(R.string.MessageSyncScreen__cancel)) + } +} + +@AllDevicePreviews +@Composable +private fun MessageSyncScreenPreview() { + Previews.Preview { + MessageSyncScreen( + state = MessageSyncScreenState( + downloadedBytes = 1.mebiBytes, + totalBytes = 3300.kibiBytes + ), + onEvent = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenEvent.kt b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenEvent.kt new file mode 100644 index 0000000000..16726a13ce --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenEvent.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.messagesync + +import org.signal.registration.util.DebugLoggableModel + +sealed class MessageSyncScreenEvent : DebugLoggableModel() { + data object LearnMoreClick : MessageSyncScreenEvent() + data object CancelClick : MessageSyncScreenEvent() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenState.kt b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenState.kt new file mode 100644 index 0000000000..2edb160c69 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenState.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.messagesync + +import org.signal.core.util.ByteSize +import org.signal.core.util.bytes +import org.signal.registration.util.DebugLoggableModel + +data class MessageSyncScreenState( + val downloadedBytes: ByteSize = 0.bytes, + val totalBytes: ByteSize = 0.bytes +) : DebugLoggableModel() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncViewModel.kt new file mode 100644 index 0000000000..b48b6e0d8f --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncViewModel.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.messagesync + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.signal.core.util.logging.Log +import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationFlowState +import org.signal.registration.RegistrationRepository +import org.signal.registration.screens.EventDrivenViewModel + +class MessageSyncViewModel( + private val repository: RegistrationRepository, + private val parentState: StateFlow, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(MessageSyncViewModel::class) + } + + private val _state = MutableStateFlow(MessageSyncScreenState()) + val state: StateFlow = _state.asStateFlow() + + // TODO [regv5] wire in message restoration state + + override suspend fun processEvent(event: MessageSyncScreenEvent) { + when (event) { + MessageSyncScreenEvent.LearnMoreClick -> error("This event is handled in the nav-entry.") + MessageSyncScreenEvent.CancelClick -> Unit // TODO [regv5] wire in to actually cancel the download. + } + } + + class Factory( + private val repository: RegistrationRepository, + private val parentState: StateFlow, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return MessageSyncViewModel(repository, parentState, parentEventEmitter) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt index 1b88b2da01..313ddb798e 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt @@ -74,6 +74,7 @@ import org.signal.registration.test.TestTags */ @Composable fun WelcomeScreen( + isLinkAndSyncAvailable: Boolean = false, onEvent: (WelcomeScreenEvents) -> Unit, modifier: Modifier = Modifier ) { @@ -103,6 +104,7 @@ fun WelcomeScreen( WindowBreakpoint.LARGE_WIDTH, WindowBreakpoint.LARGE_HEIGHT -> { LargeLayout( + isLinkAndSyncAvailable = isLinkAndSyncAvailable, onEvent = onEvent, onTermsAndPrivacyClick = onTermsAndPrivacyClick, onRestoreOrTransferClick = onRestoreOrTransferClick, @@ -238,6 +240,7 @@ private fun MediumLayout( @Composable private fun LargeLayout( + isLinkAndSyncAvailable: Boolean, onEvent: (WelcomeScreenEvents) -> Unit, onTermsAndPrivacyClick: () -> Unit, onRestoreOrTransferClick: () -> Unit, @@ -282,10 +285,16 @@ private fun LargeLayout( .padding(bottom = 8.dp) ) - PrimaryDeviceCallToActionButtons( - onEvent = onEvent, - onRestoreOrTransferClick = onRestoreOrTransferClick - ) + if (isLinkAndSyncAvailable) { + SecondaryDeviceCallToActionButtons( + onEvent = onEvent + ) + } else { + PrimaryDeviceCallToActionButtons( + onEvent = onEvent, + onRestoreOrTransferClick = onRestoreOrTransferClick + ) + } } } } diff --git a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt index e8570c5f8a..eef00f7e97 100644 --- a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt +++ b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt @@ -24,6 +24,23 @@ object TestTags { const val PERMISSIONS_NEXT_BUTTON = "permissions_next_button" const val PERMISSIONS_NOT_NOW_BUTTON = "permissions_not_now_button" + // Allow Notifications Screen + const val ALLOW_NOTIFICATIONS_SCREEN = "allow_notifications_screen" + const val ALLOW_NOTIFICATIONS_NEXT_BUTTON = "allow_notifications_next_button" + const val ALLOW_NOTIFICATIONS_NOT_NOW_BUTTON = "allow_notifications_not_now_button" + + // Link Account Screen + const val LINK_ACCOUNT_SCREEN = "link_account_screen" + const val LINK_ACCOUNT_GET_HELP_BUTTON = "link_account_get_help_button" + const val LINK_ACCOUNT_CREATE_ACCOUNT_LINK = "link_account_create_account_link" + const val LINK_ACCOUNT_DISPLAY_OVERLAY_BUTTON = "link_account_display_overlay_button" + const val LINK_ACCOUNT_HIDE_OVERLAY_BUTTON = "link_account_hide_overlay_button" + + // Message Sync Screen + const val MESSAGE_SYNC_SCREEN = "message_sync_screen" + const val MESSAGE_SYNC_LEARN_MORE_LINK = "message_sync_learn_more_link" + const val MESSAGE_SYNC_CANCEL_BUTTON = "message_sync_cancel_button" + // Phone Number Screen const val PHONE_NUMBER_SCREEN = "phone_number_screen" const val PHONE_NUMBER_COUNTRY_PICKER = "phone_number_country_picker" diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index 2c8f004bcc..bd9819b66f 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -341,4 +341,48 @@ Continue + + Allow Notifications + + Signal would like to request the notification permission. This allows your device to display alerts when new messages arrive. + + Not now + + Next + + + Syncing messages + + This may take a few minutes… + + Downloading %1$s of %2$s + + Messages and chat info are protected by end-to-end encryption, including the syncing process. + + Learn more + + Cancel + + + Scan this code to link your account + + Open Signal on your phone + + Tap your profile picture to open Signal Settings + + Tap \"Linked devices\" and \"Link new device\" + + Position the camera over this qr code + + Get help with these steps + + Maximize QR Code + + Scanned + + Failed to generate code + + Don\'t have Signal on another device? + + Create account diff --git a/feature/registration/src/test/java/org/signal/registration/RegistrationNavigationTest.kt b/feature/registration/src/test/java/org/signal/registration/RegistrationNavigationTest.kt index 35564729d7..83f58c02d2 100644 --- a/feature/registration/src/test/java/org/signal/registration/RegistrationNavigationTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/RegistrationNavigationTest.kt @@ -5,6 +5,7 @@ package org.signal.registration +import android.Manifest import android.app.Application import android.os.Looper import androidx.compose.ui.test.assertIsDisplayed @@ -15,6 +16,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.test.core.app.ApplicationProvider import com.google.accompanist.permissions.ExperimentalPermissionsApi import io.mockk.coEvery +import io.mockk.every import io.mockk.mockk import org.junit.Before import org.junit.Rule @@ -188,6 +190,178 @@ class RegistrationNavigationTest { composeTestRule.onNodeWithTag(TestTags.PERMISSIONS_SCREEN).assertIsDisplayed() } + @Test + @Config(qualifiers = "w1280dp-h800dp-xhdpi") + fun `when isLinkAndSyncAvailable is true, Welcome shows the link device button`() { + // Given + every { mockRepository.isLinkAndSyncAvailable } returns true + val permissionsState = createMockPermissionsState() + + composeTestRule.setContent { + SignalTheme { + RegistrationNavHost( + registrationRepository = mockRepository, + registrationViewModel = viewModel, + permissionsState = permissionsState + ) + } + } + + // Then + composeTestRule.onNodeWithTag(TestTags.WELCOME_LINK_DEVICE_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).assertDoesNotExist() + } + + @Test + fun `when isLinkAndSyncAvailable is true on compact display, link device button is not shown`() { + // Given + every { mockRepository.isLinkAndSyncAvailable } returns true + val permissionsState = createMockPermissionsState() + + composeTestRule.setContent { + SignalTheme { + RegistrationNavHost( + registrationRepository = mockRepository, + registrationViewModel = viewModel, + permissionsState = permissionsState + ) + } + } + + // Then - compact layout always uses the primary buttons regardless of the flag + composeTestRule.onNodeWithTag(TestTags.WELCOME_LINK_DEVICE_BUTTON).assertDoesNotExist() + composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).assertIsDisplayed() + } + + @Test + @Config(qualifiers = "w800dp-h800dp-xhdpi") + fun `when isLinkAndSyncAvailable is true on medium display, link device button is not shown`() { + // Given + every { mockRepository.isLinkAndSyncAvailable } returns true + val permissionsState = createMockPermissionsState() + + composeTestRule.setContent { + SignalTheme { + RegistrationNavHost( + registrationRepository = mockRepository, + registrationViewModel = viewModel, + permissionsState = permissionsState + ) + } + } + + // Then - medium layout always uses the primary buttons regardless of the flag + composeTestRule.onNodeWithTag(TestTags.WELCOME_LINK_DEVICE_BUTTON).assertDoesNotExist() + composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).assertIsDisplayed() + } + + @Test + @Config(qualifiers = "w1280dp-h800dp-xhdpi") + fun `when isLinkAndSyncAvailable is false, Welcome does not show the link device button`() { + // Given - relaxed mock returns false for isLinkAndSyncAvailable by default + val permissionsState = createMockPermissionsState() + + composeTestRule.setContent { + SignalTheme { + RegistrationNavHost( + registrationRepository = mockRepository, + registrationViewModel = viewModel, + permissionsState = permissionsState + ) + } + } + + // Then + composeTestRule.onNodeWithTag(TestTags.WELCOME_LINK_DEVICE_BUTTON).assertDoesNotExist() + composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).assertIsDisplayed() + } + + @Test + @Config(qualifiers = "w1280dp-h800dp-xhdpi") + fun `clicking Link Device on Welcome navigates to AllowNotifications when notifications permission is required`() { + // Given + every { mockRepository.isLinkAndSyncAvailable } returns true + val permissionsState = createMockPermissionsState() + + composeTestRule.setContent { + SignalTheme { + RegistrationNavHost( + registrationRepository = mockRepository, + registrationViewModel = viewModel, + permissionsState = permissionsState + ) + } + } + + // When + composeTestRule.onNodeWithTag(TestTags.WELCOME_LINK_DEVICE_BUTTON).performClick() + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // Then - notifications permission is required on the default Robolectric SDK (>= 33) + composeTestRule.onNodeWithTag(TestTags.ALLOW_NOTIFICATIONS_SCREEN).assertIsDisplayed() + } + + @Test + @Config(qualifiers = "w1280dp-h800dp-xhdpi") + fun `clicking Next on AllowNotifications navigates to LinkAccount`() { + // Given + every { mockRepository.isLinkAndSyncAvailable } returns true + val permissionsState = createMockPermissionsState() + Shadows.shadowOf(ApplicationProvider.getApplicationContext()) + .grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + + composeTestRule.setContent { + SignalTheme { + RegistrationNavHost( + registrationRepository = mockRepository, + registrationViewModel = viewModel, + permissionsState = permissionsState + ) + } + } + + composeTestRule.onNodeWithTag(TestTags.WELCOME_LINK_DEVICE_BUTTON).performClick() + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // When + composeTestRule.onNodeWithTag(TestTags.ALLOW_NOTIFICATIONS_NEXT_BUTTON).performClick() + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // Then + composeTestRule.onNodeWithTag(TestTags.LINK_ACCOUNT_SCREEN).assertIsDisplayed() + } + + @Test + @Config(qualifiers = "w1280dp-h800dp-xhdpi") + fun `clicking Not now on AllowNotifications navigates to LinkAccount`() { + // Given + every { mockRepository.isLinkAndSyncAvailable } returns true + val permissionsState = createMockPermissionsState() + + composeTestRule.setContent { + SignalTheme { + RegistrationNavHost( + registrationRepository = mockRepository, + registrationViewModel = viewModel, + permissionsState = permissionsState + ) + } + } + + composeTestRule.onNodeWithTag(TestTags.WELCOME_LINK_DEVICE_BUTTON).performClick() + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // When + composeTestRule.onNodeWithTag(TestTags.ALLOW_NOTIFICATIONS_NOT_NOW_BUTTON).performClick() + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // Then + composeTestRule.onNodeWithTag(TestTags.LINK_ACCOUNT_SCREEN).assertIsDisplayed() + } + @Test fun `clicking I don't have my old phone navigates to Restore`() { // Given diff --git a/feature/registration/src/test/java/org/signal/registration/screens/linkaccount/LinkAccountScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/linkaccount/LinkAccountScreenTest.kt new file mode 100644 index 0000000000..77a4984d57 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/linkaccount/LinkAccountScreenTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.linkaccount + +import android.app.Application +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.core.app.ApplicationProvider +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.ui.CoreUiDependenciesRule +import org.signal.core.ui.compose.QrCodeData +import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.registration.screens.quickrestore.QrState +import org.signal.registration.test.TestTags + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class LinkAccountScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @get:Rule + val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext()) + + @Test + fun `when Get Help is clicked, GetHelpClick event is emitted`() { + var emittedEvent: LinkAccountScreenEvent? = null + + composeTestRule.setContent { + SignalTheme { + LinkAccountScreen( + state = LinkAccountScreenState(), + onEvent = { emittedEvent = it } + ) + } + } + + composeTestRule.onNodeWithTag(TestTags.LINK_ACCOUNT_GET_HELP_BUTTON).performScrollTo().performClick() + + assert(emittedEvent == LinkAccountScreenEvent.GetHelpClick) + } + + @Test + fun `when Create Account is clicked, CreateAccountClick event is emitted`() { + var emittedEvent: LinkAccountScreenEvent? = null + + composeTestRule.setContent { + SignalTheme { + LinkAccountScreen( + state = LinkAccountScreenState(), + onEvent = { emittedEvent = it } + ) + } + } + + composeTestRule.onNodeWithTag(TestTags.LINK_ACCOUNT_CREATE_ACCOUNT_LINK).performClick() + + assert(emittedEvent == LinkAccountScreenEvent.CreateAccountClick) + } + + @Test + fun `when Display Overlay is clicked, DisplayOverlayClick event is emitted`() { + var emittedEvent: LinkAccountScreenEvent? = null + + composeTestRule.setContent { + SignalTheme { + LinkAccountScreen( + state = LinkAccountScreenState(qrCodeState = QrState.Loaded(qrCodeData = QrCodeData.forData("sgnl://test", true))), + onEvent = { emittedEvent = it } + ) + } + } + + composeTestRule.onNodeWithTag(TestTags.LINK_ACCOUNT_DISPLAY_OVERLAY_BUTTON).performClick() + + assert(emittedEvent == LinkAccountScreenEvent.DisplayOverlayClick) + } + + @Test + fun `when Hide Overlay is clicked, HideOverlayClick event is emitted`() { + var emittedEvent: LinkAccountScreenEvent? = null + + composeTestRule.setContent { + SignalTheme { + LinkAccountScreen( + state = LinkAccountScreenState( + qrCodeState = QrState.Loaded(qrCodeData = QrCodeData.forData("sgnl://test", true)), + displayQrOverlay = true + ), + onEvent = { + emittedEvent = it + } + ) + } + } + + composeTestRule.onNodeWithTag(TestTags.LINK_ACCOUNT_HIDE_OVERLAY_BUTTON).performClick() + + assert(emittedEvent == LinkAccountScreenEvent.HideOverlayClick) + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenTest.kt index 8dd9cd13c4..5b365eb24b 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenTest.kt @@ -65,6 +65,7 @@ class WelcomeScreenTest { composeTestRule.setContent { SignalTheme { WelcomeScreen( + isLinkAndSyncAvailable = true, onEvent = { event -> emittedEvent = event } @@ -73,10 +74,10 @@ class WelcomeScreenTest { } // When - composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).performClick() + composeTestRule.onNodeWithTag(TestTags.WELCOME_LINK_DEVICE_BUTTON).performClick() // Then - assert(emittedEvent == WelcomeScreenEvents.Continue) + assert(emittedEvent == WelcomeScreenEvents.LinkDevice) } @Test