diff --git a/app/src/main/java/org/thoughtcrime/securesms/DevicePinAuthEducationSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/DevicePinAuthEducationSheet.kt index 34349d8caf..5d687219da 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/DevicePinAuthEducationSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/DevicePinAuthEducationSheet.kt @@ -109,7 +109,7 @@ fun DevicePinAuthEducationSheet( @DayNightPreviews @Composable fun DevicePinAuthEducationSheetPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { DevicePinAuthEducationSheet( title = "To continue, confirm it's you", onClick = {} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt index 7abf1e327c..b1a6aa1a53 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt @@ -437,7 +437,7 @@ private fun rememberSecondaryAction( @DayNightPreviews @Composable private fun BackupAlertSheetContentPreviewGeneric() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { val backupAlert = BackupAlert.CouldNotCompleteBackup(daysSinceLastBackup = 7) val primaryActionButtonState = rememberPrimaryAction(backupAlert) { } val secondaryActionButtonState = rememberSecondaryAction(backupAlert) { } @@ -449,7 +449,7 @@ private fun BackupAlertSheetContentPreviewGeneric() { @DayNightPreviews @Composable private fun BackupAlertSheetContentPreviewPayment() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { val backupAlert = BackupAlert.FailedToRenew val primaryActionButtonState = rememberPrimaryAction(backupAlert) { } val secondaryActionButtonState = rememberSecondaryAction(backupAlert) { } @@ -461,7 +461,7 @@ private fun BackupAlertSheetContentPreviewPayment() { @DayNightPreviews @Composable private fun BackupAlertSheetContentPreviewDelete() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { val backupAlert = BackupAlert.DownloadYourBackupData( isLastDay = false, formattedSize = "2.3MB" @@ -476,7 +476,7 @@ private fun BackupAlertSheetContentPreviewDelete() { @DayNightPreviews @Composable private fun BackupAlertSheetContentPreviewDiskFull() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { val backupAlert = BackupAlert.DiskFull(requiredSpace = "12GB") val primaryActionButtonState = rememberPrimaryAction(backupAlert) { } val secondaryActionButtonState = rememberSecondaryAction(backupAlert) { } @@ -488,7 +488,7 @@ private fun BackupAlertSheetContentPreviewDiskFull() { @DayNightPreviews @Composable private fun BackupAlertSheetContentPreviewBackupFailed() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { val backupAlert = BackupAlert.BackupFailed val primaryActionButtonState = rememberPrimaryAction(backupAlert) { } val secondaryActionButtonState = rememberSecondaryAction(backupAlert) { } @@ -500,7 +500,7 @@ private fun BackupAlertSheetContentPreviewBackupFailed() { @DayNightPreviews @Composable private fun BackupAlertSheetContentPreviewCouldNotRedeemBackup() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { val backupAlert = BackupAlert.CouldNotRedeemBackup val primaryActionButtonState = rememberPrimaryAction(backupAlert) { } val secondaryActionButtonState = rememberSecondaryAction(backupAlert) { } @@ -512,7 +512,7 @@ private fun BackupAlertSheetContentPreviewCouldNotRedeemBackup() { @DayNightPreviews @Composable private fun BackupAlertSheetContentPreviewSubscriptionExpired() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { val backupAlert = BackupAlert.ExpiredAndDowngraded val primaryActionButtonState = rememberPrimaryAction(backupAlert) { } val secondaryActionButtonState = rememberSecondaryAction(backupAlert) { } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertSheetComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertSheetComponents.kt index 6e6d7024dd..0b8ef95e09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertSheetComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertSheetComponents.kt @@ -204,7 +204,7 @@ private fun BackupAlertSecondaryActionButton( @DayNightPreviews @Composable private fun BackupAlertBottomSheetContainerPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { BackupAlertBottomSheetContainer( icon = { BackupAlertIcon(iconColors = BackupsIconColors.Warning) }, title = "Test backup alert", diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/CreateBackupBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/CreateBackupBottomSheet.kt index 51fd96b8c5..3babc21e4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/CreateBackupBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/CreateBackupBottomSheet.kt @@ -137,7 +137,7 @@ private fun CreateBackupBottomSheetContent( @DayNightPreviews @Composable private fun CreateBackupBottomSheetContentPaidPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { CreateBackupBottomSheetContent( isPaidTier = true, onBackupNowClick = {} @@ -148,7 +148,7 @@ private fun CreateBackupBottomSheetContentPaidPreview() { @DayNightPreviews @Composable private fun CreateBackupBottomSheetContentFreePreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { CreateBackupBottomSheetContent( isPaidTier = false, onBackupNowClick = {} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MediaBackupsAreOffBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MediaBackupsAreOffBottomSheet.kt index 48d600dc59..38367db4e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MediaBackupsAreOffBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MediaBackupsAreOffBottomSheet.kt @@ -159,7 +159,7 @@ private fun SheetContent( @DayNightPreviews @Composable private fun BackupAlertSheetContentPreviewMedia() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { SheetContent( mediaBackupsAreOff = BackupAlert.MediaBackupsAreOff(endOfPeriodSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds), paidBackupType = MessageBackupsType.Paid( diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/NoManualBackupBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/NoManualBackupBottomSheet.kt index a22dfe3b90..27c235b9a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/NoManualBackupBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/NoManualBackupBottomSheet.kt @@ -74,7 +74,7 @@ private fun NoManualBackupSheetContent( @DayNightPreviews @Composable private fun NoManualBackupSheetContentPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { NoManualBackupSheetContent( durationSinceLastBackup = 30.days ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/NoRemoteStorageSpaceAvailableBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/NoRemoteStorageSpaceAvailableBottomSheet.kt index 0fb71bed92..e6216b3893 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/NoRemoteStorageSpaceAvailableBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/NoRemoteStorageSpaceAvailableBottomSheet.kt @@ -111,7 +111,7 @@ private fun NoRemoteStorageSpaceAvailableBottomSheetContent( @DayNightPreviews @Composable private fun NoRemoteStorageSpaceAvailableBottomSheetContentPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { NoRemoteStorageSpaceAvailableBottomSheetContent({}, {}, {}) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt index 8a900ea5ed..54ac488741 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt @@ -521,7 +521,7 @@ private fun SaveKeyConfirmationDialogPreview() { @DayNightPreviews @Composable private fun CreateNewBackupKeySheetContentPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { Column { CreateNewBackupKeySheetContent() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyVerifyScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyVerifyScreen.kt index 7d7d0f08a0..608a6c3c3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyVerifyScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyVerifyScreen.kt @@ -205,7 +205,7 @@ private fun MessageBackupsKeyRecordScreenPreview() { @DayNightPreviews @Composable private fun BottomSheetContentPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { BottomSheetContent({}, {}) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToEnableOptimizedStorageSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToEnableOptimizedStorageSheet.kt index 2be60818e8..f49d1338f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToEnableOptimizedStorageSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToEnableOptimizedStorageSheet.kt @@ -148,7 +148,7 @@ private fun UpgradeToEnableOptimizedStorageSheetContent( @DayNightPreviews @Composable private fun UpgradeToEnableOptimizedStorageSheetContentPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { UpgradeToEnableOptimizedStorageSheetContent( messageBackupsType = testBackupTypes()[1] as MessageBackupsType.Paid, isSubscribeEnabled = true diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkBottomSheetDialogFragment.kt index 514f5bb913..6bc8b9c596 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkBottomSheetDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkBottomSheetDialogFragment.kt @@ -328,7 +328,7 @@ private fun CreateCallLinkBottomSheetContent( @DayNightPreviews @Composable private fun CreateCallLinkBottomSheetContentPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { CreateCallLinkBottomSheetContent( callLink = CallLinkTable.CallLink( recipientId = RecipientId.UNKNOWN, diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreens.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreens.kt index 8177a508e1..85f6a06c74 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreens.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreens.kt @@ -489,7 +489,7 @@ private fun CallQualityScreenPreview() { @PreviewLightDark @Composable private fun HowWasYourCallPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { Column { HowWasYourCall( onGreatClick = {}, @@ -503,7 +503,7 @@ private fun HowWasYourCallPreview() { @PreviewLightDark @Composable private fun WhatIssuesDidYouHavePreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { var userSelection by remember { mutableStateOf>(emptySet()) } Column { @@ -524,7 +524,7 @@ private fun WhatIssuesDidYouHavePreview() { @PreviewLightDark @Composable private fun HelpUsImprovePreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { Column { HelpUsImprove( isShareDebugLogSelected = true, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/compose/ConnectivityWarningBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/compose/ConnectivityWarningBottomSheet.kt index 06fe56d682..b142f9795a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/compose/ConnectivityWarningBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/compose/ConnectivityWarningBottomSheet.kt @@ -105,7 +105,7 @@ private fun Sheet(onDismiss: () -> Unit = {}) { @DayNightPreviews @Composable private fun ConnectivityWarningSheetPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { Sheet() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/compose/DeviceSpecificNotificationBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/compose/DeviceSpecificNotificationBottomSheet.kt index 2c375986c0..cc71016175 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/compose/DeviceSpecificNotificationBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/compose/DeviceSpecificNotificationBottomSheet.kt @@ -127,7 +127,7 @@ private fun DeviceSpecificSheet(onContinue: () -> Unit = {}, onDismiss: () -> Un @DayNightPreviews @Composable private fun DeviceSpecificSheetPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { DeviceSpecificSheet() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/SubscriptionNotFoundBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/SubscriptionNotFoundBottomSheet.kt index fcda22fd26..f9451c0341 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/SubscriptionNotFoundBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/SubscriptionNotFoundBottomSheet.kt @@ -167,7 +167,7 @@ private fun SubscriptionNotFoundReason(text: String) { @DayNightPreviews @Composable private fun SubscriptionNotFoundContentPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { Column { SubscriptionNotFoundContent() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersEducationSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersEducationSheet.kt index e672313bb8..ace7d59e98 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersEducationSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersEducationSheet.kt @@ -109,7 +109,7 @@ fun EducationRow(text: String, painter: Painter) { @DayNightPreviews @Composable fun ChatFoldersEducationSheetPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { FolderEducationSheet(onClick = {}) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PendingParticipantsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PendingParticipantsBottomSheet.kt index 1c7e0e3141..fcc5bfe35c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PendingParticipantsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PendingParticipantsBottomSheet.kt @@ -135,7 +135,7 @@ class PendingParticipantsBottomSheet : ComposeBottomSheetDialogFragment() { @NightPreview @Composable private fun PendingParticipantsSheetPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { PendingParticipantsSheet( pendingParticipants = listOf( PendingParticipantCollection.State.PENDING, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt index 2bd40b1ec2..8515882d8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt @@ -630,7 +630,7 @@ private fun ThreeUnknownAvatars() { @NightPreview @Composable private fun UnknownMembersRowPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { Column { UnknownMembersRow(unknownMemberCount = 1, allCallMembersAreUnknown = true) UnknownMembersRow(unknownMemberCount = 1, allCallMembersAreUnknown = false) 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 74b43e2356..3666394819 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 @@ -106,7 +106,7 @@ class CallLinkIncomingRequestSheet : ComposeBottomSheetDialogFragment() { @NightPreview @Composable private fun CallLinkIncomingRequestSheetContentPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { CallLinkIncomingRequestSheetContent( state = CallLinkIncomingRequestState( name = "Miles Morales", diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallAudioToggleButton.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallAudioToggleButton.kt index 7ac30ad5fd..9a0f44c2a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallAudioToggleButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallAudioToggleButton.kt @@ -266,7 +266,7 @@ private fun LegacyAudioPickerContent( @NightPreview @Composable private fun CallAudioPickerSheetContentPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { Column { LegacyAudioPickerContent( toggleButtonOutputState = ToggleButtonOutputState().apply { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MediaNoLongerAvailableBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MediaNoLongerAvailableBottomSheet.kt index 8f212e687b..e0599708d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MediaNoLongerAvailableBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MediaNoLongerAvailableBottomSheet.kt @@ -118,7 +118,7 @@ private fun MediaNoLongerAvailableBottomSheetContent( @DayNightPreviews @Composable private fun MediaNoLongerAvailableBottomSheetContentPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { MediaNoLongerAvailableBottomSheetContent() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/UnverifiedProfileNameBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/UnverifiedProfileNameBottomSheet.kt index 0d18592932..495fcc84cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/UnverifiedProfileNameBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/UnverifiedProfileNameBottomSheet.kt @@ -159,7 +159,7 @@ fun InfoRow(text: String) { @DayNightPreviews @Composable private fun ProfileNameSheetPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { ProfileNameSheet() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/AddToFolderBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/AddToFolderBottomSheet.kt index 3cd102a0d1..6fc8644128 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/AddToFolderBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/AddToFolderBottomSheet.kt @@ -251,7 +251,7 @@ private fun isThreadListAlreadyAdded(folder: ChatFolderRecord, threadIds: List Unit) { @DayNightPreviews @Composable fun FinishedSheetSheetPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { FinishedSheet(onClick = {}) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceIntroBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceIntroBottomSheet.kt index 29bfb4ca6d..87a1ada4e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceIntroBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceIntroBottomSheet.kt @@ -76,7 +76,7 @@ fun EducationSheet(onClick: () -> Unit) { @DayNightPreviews @Composable fun EducationSheetPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { EducationSheet(onClick = {}) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceLearnMoreBottomSheetFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceLearnMoreBottomSheetFragment.kt index aa6b57886e..3f0974ec50 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceLearnMoreBottomSheetFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceLearnMoreBottomSheetFragment.kt @@ -116,7 +116,7 @@ private fun LinkedDeviceInformationRow( @DayNightPreviews @Composable fun LearnMorePreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { LearnMoreSheet() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSyncBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSyncBottomSheet.kt index 21377d46cd..3007d2288f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSyncBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSyncBottomSheet.kt @@ -128,7 +128,7 @@ private fun SheetOption( @DayNightPreviews @Composable fun SyncSheetSheetSheetPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { SyncSheet(onLink = {}) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionDeniedBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionDeniedBottomSheet.kt index 47e84ae24b..2f184d8696 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionDeniedBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionDeniedBottomSheet.kt @@ -77,7 +77,7 @@ class PermissionDeniedBottomSheet private constructor() : ComposeBottomSheetDial @DayNightPreviews @Composable private fun PermissionDeniedSheetContentPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { PermissionDeniedSheetContent( titleRes = R.string.AttachmentManager_signal_allow_access_location, subtitleRes = R.string.AttachmentManager_signal_to_send_location, diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyScreen.kt index ef27879de5..cfbd771afa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyScreen.kt @@ -325,7 +325,7 @@ private fun NoBackupKeyBottomSheet( @DayNightPreviews @Composable private fun NoBackupKeyBottomSheetPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { NoBackupKeyBottomSheet( showSecondParagraph = true ) @@ -335,7 +335,7 @@ private fun NoBackupKeyBottomSheetPreview() { @DayNightPreviews @Composable private fun NoBackupKeyBottomSheetNoSecondParagraphPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { NoBackupKeyBottomSheet( showSecondParagraph = false ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/welcome/RestoreWelcomeBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/welcome/RestoreWelcomeBottomSheet.kt index 1c621be1d3..8b42781363 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/welcome/RestoreWelcomeBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/welcome/RestoreWelcomeBottomSheet.kt @@ -104,7 +104,7 @@ private fun Sheet( @Composable @DayNightPreviews private fun SheetPreview() { - Previews.BottomSheetPreview { + Previews.BottomSheetContentPreview { Sheet() } } diff --git a/core-ui/build.gradle.kts b/core-ui/build.gradle.kts index 09e06c493a..3f87fd709e 100644 --- a/core-ui/build.gradle.kts +++ b/core-ui/build.gradle.kts @@ -32,4 +32,5 @@ dependencies { debugApi(libs.androidx.compose.ui.tooling.core) api(libs.androidx.fragment.compose) implementation(libs.kotlinx.serialization.json) + api(libs.google.zxing.core) } diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/BottomSheets.kt b/core-ui/src/main/java/org/signal/core/ui/compose/BottomSheets.kt index 139dd8040a..06a4c6586e 100644 --- a/core-ui/src/main/java/org/signal/core/ui/compose/BottomSheets.kt +++ b/core-ui/src/main/java/org/signal/core/ui/compose/BottomSheets.kt @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +@file:OptIn(ExperimentalMaterial3Api::class) + package org.signal.core.ui.compose import androidx.compose.foundation.background @@ -10,7 +12,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -18,6 +24,22 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp object BottomSheets { + + @Composable + fun BottomSheet( + onDismissRequest: () -> Unit, + sheetState: SheetState = rememberModalBottomSheetState(), + content: @Composable () -> Unit + ) { + return ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = { Handle() } + ) { + content() + } + } + /** * Handle for bottom sheets */ diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/Previews.kt b/core-ui/src/main/java/org/signal/core/ui/compose/Previews.kt index 8b83dc7c1d..3b96657918 100644 --- a/core-ui/src/main/java/org/signal/core/ui/compose/Previews.kt +++ b/core-ui/src/main/java/org/signal/core/ui/compose/Previews.kt @@ -3,10 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +@file:OptIn(ExperimentalMaterial3Api::class) + package org.signal.core.ui.compose import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -16,6 +21,9 @@ import androidx.compose.ui.unit.LayoutDirection import org.signal.core.ui.compose.theme.SignalTheme object Previews { + /** + * The default wrapper for previews. Properly sets the theme and provides a drawing surface. + */ @Composable fun Preview( forceRtl: Boolean = false, @@ -34,8 +42,12 @@ object Previews { } } + /** + * A preview wrapper for bottom sheet content. There will be no bottom sheet UI trimmings, just the content of the sheet. Properly sets the theme and an + * appropriate surface color. + */ @Composable - fun BottomSheetPreview( + fun BottomSheetContentPreview( forceRtl: Boolean = false, content: @Composable () -> Unit ) { @@ -51,4 +63,29 @@ object Previews { } } } + + /** + * A preview wrapper for a bottom sheet. You'll see the full bottom sheet UI in the expanded state. + */ + @Composable + fun BottomSheetPreview( + forceRtl: Boolean = false, + content: @Composable () -> Unit + ) { + val dir = if (forceRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + + CompositionLocalProvider(LocalLayoutDirection provides dir) { + SignalTheme(incognitoKeyboardEnabled = false) { + val sheetState = SheetState( + skipPartiallyExpanded = true, + initialValue = SheetValue.Expanded, + positionalThreshold = { 1f }, + velocityThreshold = { 1f } + ) + BottomSheets.BottomSheet(sheetState = sheetState, onDismissRequest = {}) { + content() + } + } + } + } } diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/QrCode.kt b/core-ui/src/main/java/org/signal/core/ui/compose/QrCode.kt new file mode 100644 index 0000000000..0b0941d8c0 --- /dev/null +++ b/core-ui/src/main/java/org/signal/core/ui/compose/QrCode.kt @@ -0,0 +1,163 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.imageResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import org.signal.core.ui.R +import kotlin.math.ceil +import kotlin.math.floor + +/** + * Shows a QRCode that represents the provided data. Includes a Signal logo in the middle. + */ +@Composable +fun QrCode( + data: QrCodeData, + modifier: Modifier = Modifier, + foregroundColor: Color = Color.Black, + backgroundColor: Color = Color.White, + deadzonePercent: Float = 0.35f +) { + val logo = ImageBitmap.imageResource(R.drawable.qrcode_logo) + + Column( + modifier = modifier + .drawBehind { + drawQr( + data = data, + foregroundColor = foregroundColor, + backgroundColor = backgroundColor, + deadzonePercent = deadzonePercent, + logo = logo + ) + } + ) { + } +} + +fun DrawScope.drawQr( + data: QrCodeData, + foregroundColor: Color, + backgroundColor: Color, + deadzonePercent: Float, + logo: ImageBitmap? +) { + val deadzonePaddingPercent = 0.045f + + // We want an even number of dots on either side of the deadzone + val deadzoneRadius: Int = if (data.canSupportIconOverlay) { + (data.height * (deadzonePercent + deadzonePaddingPercent)).toInt().let { candidateDeadzoneHeight -> + if ((data.height - candidateDeadzoneHeight) % 2 == 0) { + candidateDeadzoneHeight + } else { + candidateDeadzoneHeight + 1 + } + } / 2 + } else { + 0 + } + + val cellWidthPx: Float = size.width / data.width + val cornerRadius = CornerRadius(7f, 7f) + val deadzone = Circle(center = IntOffset(data.width / 2, data.height / 2), radius = deadzoneRadius) + + for (x in 0 until data.width) { + for (y in 0 until data.height) { + val position = IntOffset(x, y) + + if (data.get(position) && !deadzone.contains(position)) { + val filledAbove = IntOffset(x, y - 1).let { data.get(it) && !deadzone.contains(it) } + val filledBelow = IntOffset(x, y + 1).let { data.get(it) && !deadzone.contains(it) } + val filledLeft = IntOffset(x - 1, y).let { data.get(it) && !deadzone.contains(it) } + val filledRight = IntOffset(x + 1, y).let { data.get(it) && !deadzone.contains(it) } + + val path = Path().apply { + addRoundRect( + RoundRect( + rect = Rect( + topLeft = Offset(floor(x * cellWidthPx), floor(y * cellWidthPx - 1)), + bottomRight = Offset(ceil((x + 1) * cellWidthPx), ceil((y + 1) * cellWidthPx + 1)) + ), + topLeft = if (filledAbove || filledLeft) CornerRadius.Zero else cornerRadius, + topRight = if (filledAbove || filledRight) CornerRadius.Zero else cornerRadius, + bottomLeft = if (filledBelow || filledLeft) CornerRadius.Zero else cornerRadius, + bottomRight = if (filledBelow || filledRight) CornerRadius.Zero else cornerRadius + ) + ) + } + + drawPath( + path = path, + color = if (data.get(position)) foregroundColor else backgroundColor + ) + } + } + } + + if (data.canSupportIconOverlay) { + // Logo border + val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2 + drawCircle( + color = foregroundColor, + radius = logoBorderRadiusPx, + style = Stroke(width = cellWidthPx * 0.75f), + center = this.center + ) + + // Logo + val logoWidthPx = (((deadzonePercent - deadzonePaddingPercent) * 0.6f) * size.width).toInt() + val logoOffsetPx = ((size.width - logoWidthPx) / 2).toInt() + if (logo != null) { + drawImage( + image = logo, + dstOffset = IntOffset(logoOffsetPx, logoOffsetPx), + dstSize = IntSize(logoWidthPx, logoWidthPx), + colorFilter = ColorFilter.tint(foregroundColor) + ) + } + } +} + +@Preview +@Composable +private fun Preview() { + Surface { + QrCode( + data = QrCodeData.forData("https://signal.org"), + modifier = Modifier.size(350.dp) + ) + } +} + +private data class Circle( + val center: IntOffset, + val radius: Int +) { + fun contains(position: IntOffset): Boolean { + val diff = center - position + return diff.x * diff.x + diff.y * diff.y < radius * radius + } +} diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/QrCodeData.kt b/core-ui/src/main/java/org/signal/core/ui/compose/QrCodeData.kt new file mode 100644 index 0000000000..d6dc25abe1 --- /dev/null +++ b/core-ui/src/main/java/org/signal/core/ui/compose/QrCodeData.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.compose + +import androidx.annotation.WorkerThread +import androidx.compose.ui.unit.IntOffset +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import java.util.BitSet + +/** + * Efficient representation of raw QR code data. Stored as an X/Y grid of points, where (0, 0) is the top left corner. + * X increases as you move right, and Y increases as you go down. + */ +class QrCodeData( + val width: Int, + val height: Int, + val canSupportIconOverlay: Boolean, + private val bits: BitSet +) { + + /** + * Returns true if the bit in the QR code is "on" for the specified position, false if it is "off" or out of bounds. + */ + fun get(position: IntOffset): Boolean { + val (x, y) = position + return if (x < 0 || y < 0 || x >= width || y >= height) { + false + } else { + bits.get(y * width + x) + } + } + + companion object { + + /** + * Converts the provided string data into a QR representation. + * + * @param supportIconOverlay indicates data can be rendered with the icon overlay. Rendering with an icon relies on more error correction + * data in the QR which requires a denser rendering which is sometimes not easily scanned by our scanner. Set to false if data is expected to be + * long to prevent scanning issues. + */ + @WorkerThread + fun forData(data: String, supportIconOverlay: Boolean = true): QrCodeData { + val qrCodeWriter = QRCodeWriter() + val hints = mapOf(EncodeHintType.ERROR_CORRECTION to if (supportIconOverlay) ErrorCorrectionLevel.Q.toString() else ErrorCorrectionLevel.L.toString()) + + val padded = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, 64, 64, hints) + val dimens = padded.enclosingRectangle + val xStart = dimens[0] + val yStart = dimens[1] + val width = dimens[2] + val height = dimens[3] + val bitSet = BitSet(width * height) + + for (x in xStart until xStart + width) { + for (y in yStart until yStart + height) { + if (padded.get(x, y)) { + val destX = x - xStart + val destY = y - yStart + bitSet.set(destY * width + destX) + } + } + } + + return QrCodeData(width, height, supportIconOverlay, bitSet) + } + } +} diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/SheetExtensions.kt b/core-ui/src/main/java/org/signal/core/ui/compose/SheetExtensions.kt new file mode 100644 index 0000000000..638cc64d79 --- /dev/null +++ b/core-ui/src/main/java/org/signal/core/ui/compose/SheetExtensions.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.compose + +import android.os.Build +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Simple helper to dismiss the sheet and run a callback when the animation is finished. + * In unit tests, set skipAnimations = true to invoke the callback immediately. + */ +@OptIn(ExperimentalMaterial3Api::class) +fun SheetState.dismissWithAnimation( + scope: CoroutineScope, + skipAnimations: Boolean = Build.MODEL.equals("robolectric", ignoreCase = true), + onComplete: () -> Unit +) { + if (skipAnimations) { + onComplete() + return + } + + scope.launch { + this@dismissWithAnimation.hide() + onComplete() + } +} 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 new file mode 100644 index 0000000000..36d8791375 --- /dev/null +++ b/core-ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.compose + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.R + +/** + * Signal icon library with all available icons. + */ +enum class SignalIcons( + @DrawableRes val drawableRes: Int, + val displayName: String +) { + Keyboard(R.drawable.ic_keyboard_24, "Keyboard"), + Camera(R.drawable.symbol_camera_24, "Camera"), + Phone(R.drawable.symbol_phone_24, "Phone"), + QrCode(R.drawable.symbol_qrcode_24, "QR Code"); + + val painter: Painter + @Composable + get() = painterResource(drawableRes) +} + +@DayNightPreviews +@Composable +private fun SignalIconsPreview() { + Previews.Preview { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 80.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(SignalIcons.entries) { icon -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + painter = icon.painter, + contentDescription = icon.displayName, + modifier = Modifier.size(24.dp) + ) + Text( + text = icon.displayName, + style = MaterialTheme.typography.labelSmall + ) + } + } + } + } +} diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/theme/SignalTheme.kt b/core-ui/src/main/java/org/signal/core/ui/compose/theme/SignalTheme.kt index f5ac49486d..dbbc8ea317 100644 --- a/core-ui/src/main/java/org/signal/core/ui/compose/theme/SignalTheme.kt +++ b/core-ui/src/main/java/org/signal/core/ui/compose/theme/SignalTheme.kt @@ -190,7 +190,7 @@ private val darkSnackbarColors = SnackbarColors( @Composable fun SignalTheme( isDarkMode: Boolean = LocalConfiguration.current.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES, - incognitoKeyboardEnabled: Boolean, + incognitoKeyboardEnabled: Boolean = false, content: @Composable () -> Unit ) { val extendedColors = if (isDarkMode) darkExtendedColors else lightExtendedColors diff --git a/core-ui/src/main/java/org/signal/core/ui/navigation/ResultEffect.kt b/core-ui/src/main/java/org/signal/core/ui/navigation/ResultEffect.kt new file mode 100644 index 0000000000..6f9c77fba6 --- /dev/null +++ b/core-ui/src/main/java/org/signal/core/ui/navigation/ResultEffect.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.signal.core.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect + +/** + * An Effect to provide a result even between different screens + * + * The trailing lambda provides the result from a flow of results. + * + * @param resultEventBus the ResultEventBus to retrieve the result from. The default value + * is read from the `LocalResultEventBus` composition local. + * @param resultKey the key that should be associated with this effect + * @param onResult the callback to invoke when a result is received + */ +@Composable +inline fun ResultEffect( + resultEventBus: ResultEventBus = LocalResultEventBus.current, + resultKey: String = T::class.toString(), + crossinline onResult: suspend (T) -> Unit +) { + LaunchedEffect(resultKey, resultEventBus.channelMap[resultKey]) { + resultEventBus.getResultFlow(resultKey)?.collect { result -> + onResult.invoke(result as T) + } + } +} diff --git a/core-ui/src/main/java/org/signal/core/ui/navigation/ResultEventBus.kt b/core-ui/src/main/java/org/signal/core/ui/navigation/ResultEventBus.kt new file mode 100644 index 0000000000..c9bceb2403 --- /dev/null +++ b/core-ui/src/main/java/org/signal/core/ui/navigation/ResultEventBus.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.signal.core.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.compositionLocalOf +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.BUFFERED +import kotlinx.coroutines.flow.receiveAsFlow + +/** + * Local for receiving results in a [ResultEventBus] + */ +object LocalResultEventBus { + private val LocalResultEventBus: ProvidableCompositionLocal = + compositionLocalOf { null } + + /** + * The current [ResultEventBus] + */ + val current: ResultEventBus + @Composable + get() = LocalResultEventBus.current ?: error("No ResultEventBus has been provided") + + /** + * Provides a [ResultEventBus] to the composition + */ + infix fun provides( + bus: ResultEventBus + ): ProvidedValue { + return LocalResultEventBus.provides(bus) + } +} + +/** + * An EventBus for passing results between multiple sets of screens. + * + * It provides a solution for event based results. + */ +class ResultEventBus { + /** + * Map from the result key to a channel of results. + */ + val channelMap: MutableMap> = mutableMapOf() + + /** + * Provides a flow for the given resultKey. + */ + inline fun getResultFlow(resultKey: String = T::class.toString()) = + channelMap[resultKey]?.receiveAsFlow() + + /** + * Sends a result into the channel associated with the given resultKey. + */ + inline fun sendResult(resultKey: String = T::class.toString(), result: T) { + if (!channelMap.contains(resultKey)) { + channelMap[resultKey] = Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND) + } + channelMap[resultKey]?.trySend(result) + } + + /** + * Removes all results associated with the given key from the store. + */ + inline fun removeResult(resultKey: String = T::class.toString()) { + channelMap.remove(resultKey) + } +} diff --git a/core-ui/src/main/res/drawable/ic_keyboard_24.xml b/core-ui/src/main/res/drawable/ic_keyboard_24.xml new file mode 100644 index 0000000000..7781422e8c --- /dev/null +++ b/core-ui/src/main/res/drawable/ic_keyboard_24.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + diff --git a/core-ui/src/main/res/drawable/qrcode_logo.png b/core-ui/src/main/res/drawable/qrcode_logo.png new file mode 100644 index 0000000000..1c3d57989c Binary files /dev/null and b/core-ui/src/main/res/drawable/qrcode_logo.png differ diff --git a/core-ui/src/main/res/drawable/symbol_camera_24.xml b/core-ui/src/main/res/drawable/symbol_camera_24.xml new file mode 100644 index 0000000000..9270ddabc5 --- /dev/null +++ b/core-ui/src/main/res/drawable/symbol_camera_24.xml @@ -0,0 +1,14 @@ + + + + diff --git a/core-ui/src/main/res/drawable/symbol_phone_24.xml b/core-ui/src/main/res/drawable/symbol_phone_24.xml new file mode 100644 index 0000000000..c011052eb4 --- /dev/null +++ b/core-ui/src/main/res/drawable/symbol_phone_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core-ui/src/main/res/drawable/symbol_qrcode_24.xml b/core-ui/src/main/res/drawable/symbol_qrcode_24.xml new file mode 100644 index 0000000000..9a2b327532 --- /dev/null +++ b/core-ui/src/main/res/drawable/symbol_qrcode_24.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/core-util-jvm/build.gradle.kts b/core-util-jvm/build.gradle.kts index ee4cf31a75..e5f480a02b 100644 --- a/core-util-jvm/build.gradle.kts +++ b/core-util-jvm/build.gradle.kts @@ -56,6 +56,8 @@ dependencies { implementation(libs.google.libphonenumber) implementation(libs.rxjava3.rxjava) implementation(libs.rxjava3.rxkotlin) + implementation(libs.kotlinx.serialization.json) + implementation(libs.libsignal.client) testImplementation(testLibs.junit.junit) testImplementation(testLibs.assertk) diff --git a/core-util-jvm/src/main/java/org/signal/core/util/serialization/JsonExtensions.kt b/core-util-jvm/src/main/java/org/signal/core/util/serialization/JsonExtensions.kt new file mode 100644 index 0000000000..27c4b140e4 --- /dev/null +++ b/core-util-jvm/src/main/java/org/signal/core/util/serialization/JsonExtensions.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.util.serialization + +import kotlinx.serialization.json.Json + +/** + * Just like [Json.decodeFromString], except in the case of a parsing failure, this will return null instead of throwing. + */ +inline fun Json.decodeFromStringOrNull(string: String): T? { + return runCatching { decodeFromString(string) }.getOrNull() +} diff --git a/core-util-jvm/src/main/java/org/signal/core/util/serialization/JsonSerializers.kt b/core-util-jvm/src/main/java/org/signal/core/util/serialization/JsonSerializers.kt new file mode 100644 index 0000000000..aef90abb97 --- /dev/null +++ b/core-util-jvm/src/main/java/org/signal/core/util/serialization/JsonSerializers.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.util.serialization + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import org.signal.core.util.Base64 +import org.signal.libsignal.protocol.ecc.ECPublicKey +import org.signal.libsignal.protocol.kem.KEMPublicKey + +class ByteArrayToBase64Serializer() : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ByteArray", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): ByteArray { + return Base64.decode(decoder.decodeString()) + } + + override fun serialize(encoder: Encoder, value: ByteArray) { + encoder.encodeString(Base64.encodeWithPadding(value)) + } +} + +class KEMPublicKeyToBase64Serializer() : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("KEMPublicKey", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): KEMPublicKey { + return KEMPublicKey(Base64.decode(decoder.decodeString())) + } + + override fun serialize(encoder: Encoder, value: KEMPublicKey) { + encoder.encodeString(Base64.encodeWithPadding(value.serialize())) + } +} + +class ECPublicKeyToBase64Serializer() : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ECPublicKey", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): ECPublicKey { + return ECPublicKey(Base64.decode(decoder.decodeString())) + } + + override fun serialize(encoder: Encoder, value: ECPublicKey) { + encoder.encodeString(Base64.encodeWithPadding(value.serialize())) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7f00f16981..3c132193c1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,8 +7,10 @@ androidx-activity = "1.9.3" androidx-camera = "1.3.4" androidx-fragment = "1.8.5" androidx-lifecycle = "2.8.7" +androidx-lifecycle-navigation3 = "2.10.0" androidx-media3 = "1.5.1" androidx-navigation = "2.8.5" +androidx-navigation3-core = "1.0.0" androidx-window = "1.3.0" glide = "4.15.1" gradle = "8.9.0" @@ -21,6 +23,7 @@ nanohttpd = "2.3.1" navigation-safe-args-gradle-plugin = "2.8.5" protobuf-gradle-plugin = "0.9.0" ktlint = "12.1.1" +ui-test-junit4 = "1.9.4" [plugins] android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } @@ -94,7 +97,10 @@ androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "andr androidx-navigation-fragment-ktx = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +androidx-navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "androidx-navigation" } androidx-navigation-safe-args-gradle-plugin = { module = "androidx.navigation:navigation-safe-args-gradle-plugin", version.ref = "navigation-safe-args-gradle-plugin" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "androidx-navigation3-core" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "androidx-navigation3-core" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } androidx-lifecycle-livedata-core = { module = "androidx.lifecycle:lifecycle-livedata", version.ref = "androidx-lifecycle" } androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidx-lifecycle" } @@ -103,6 +109,7 @@ androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecyc androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidx-lifecycle" } androidx-lifecycle-reactivestreams-ktx = { module = "androidx.lifecycle:lifecycle-reactivestreams-ktx", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle-navigation3" } androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidx-camera" } androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidx-camera" } androidx-camera-extensions = { module = "androidx.camera:camera-extensions", version.ref = "androidx-camera" } @@ -187,6 +194,7 @@ jknack-handlebars = "com.github.jknack:handlebars:4.0.7" mp4parser-isoparser = { module = "org.mp4parser:isoparser", version.ref = "mp4parser" } mp4parser-streaming = { module = "org.mp4parser:streaming", version.ref = "mp4parser" } mp4parser-muxer = { module = "org.mp4parser:muxer", version.ref = "mp4parser" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "ui-test-junit4" } [bundles] media3 = ["androidx-media3-exoplayer", "androidx-media3-session", "androidx-media3-ui"] diff --git a/gradle/test-libs.versions.toml b/gradle/test-libs.versions.toml index c388d7889a..004953242f 100644 --- a/gradle/test-libs.versions.toml +++ b/gradle/test-libs.versions.toml @@ -4,7 +4,7 @@ [versions] androidx-test = "1.5.0" androidx-test-ext-junit = "1.1.5" -robolectric = "4.10.3" +robolectric = "4.15.1" espresso = "3.4.0" [libraries] diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 6ba8d2ee1a..f4df6f052b 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -30,6 +30,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -44,6 +52,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -87,6 +103,32 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -111,6 +153,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -185,6 +243,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -270,6 +336,18 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + @@ -581,6 +659,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -730,6 +813,19 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + @@ -750,6 +846,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -782,6 +894,19 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + @@ -802,6 +927,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -869,6 +1010,19 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + @@ -889,6 +1043,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -941,6 +1111,19 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + @@ -961,6 +1144,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1281,6 +1480,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1301,6 +1516,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1321,6 +1552,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1341,6 +1588,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1472,6 +1735,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1492,6 +1771,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1556,6 +1851,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1576,6 +1887,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1608,6 +1935,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1628,6 +1971,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1680,6 +2039,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1700,6 +2075,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1760,6 +2151,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1780,6 +2187,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1800,6 +2223,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1820,6 +2259,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1840,6 +2295,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1872,6 +2343,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1892,6 +2379,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1932,6 +2435,16 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + @@ -1952,6 +2465,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1972,6 +2501,16 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + @@ -1992,6 +2531,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -2052,6 +2607,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -2072,6 +2643,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -2124,6 +2711,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -2144,6 +2747,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -2196,6 +2815,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -2216,6 +2851,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -2315,6 +2966,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -2327,6 +2983,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2334,6 +2998,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2382,6 +3054,19 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + @@ -2814,6 +3499,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2896,6 +3589,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2961,6 +3662,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -3007,6 +3716,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -3089,6 +3806,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -3164,6 +3889,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -3281,6 +4014,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -3449,6 +4190,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -3541,6 +4290,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -3591,6 +4348,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -3633,6 +4398,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -3719,6 +4492,19 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + @@ -3797,6 +4583,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -3847,6 +4641,50 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3891,6 +4729,27 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + + + + + + @@ -4000,6 +4859,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -4048,6 +4915,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -4102,6 +4977,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -4212,6 +5095,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -4298,6 +5189,30 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + + + + + + + + + @@ -4379,6 +5294,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -4682,6 +5605,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -4706,6 +5637,70 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4927,6 +5922,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -4947,6 +5950,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -4969,6 +5980,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -4991,6 +6010,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -5065,6 +6092,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -5204,6 +6239,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -5274,6 +6314,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -7461,6 +8506,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -7644,6 +8694,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -7693,6 +8748,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -7805,6 +8865,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -7848,6 +8913,17 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + @@ -8000,6 +9076,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -8042,6 +9123,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -11168,6 +12254,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -11385,6 +12476,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -11804,6 +12900,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -11866,6 +12967,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -12565,6 +13671,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -12615,6 +13726,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -12641,6 +13757,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -12684,6 +13805,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -12696,6 +13825,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -12708,6 +13845,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -12715,6 +13860,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -12727,6 +13877,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -12734,6 +13892,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -12746,6 +13909,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -12758,6 +13929,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -12770,6 +13949,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -12782,6 +13969,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -12794,6 +13989,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -12801,6 +14004,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -12813,6 +14021,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -12926,6 +14142,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/InvalidRegistrationSessionIdException.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/InvalidRegistrationSessionIdException.kt new file mode 100644 index 0000000000..1e6c88ddc0 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/InvalidRegistrationSessionIdException.kt @@ -0,0 +1,8 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.push.exceptions + +class InvalidRegistrationSessionIdException : NonSuccessfulResponseCodeException(400) diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index f08f5c318e..5a7471796e 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -44,6 +44,7 @@ import org.whispersystems.signalservice.api.push.exceptions.ExpectationFailedExc import org.whispersystems.signalservice.api.push.exceptions.ExternalServiceFailureException; import org.whispersystems.signalservice.api.push.exceptions.HttpConflictException; import org.whispersystems.signalservice.api.push.exceptions.IncorrectRegistrationRecoveryPasswordException; +import org.whispersystems.signalservice.api.push.exceptions.InvalidRegistrationSessionIdException; import org.whispersystems.signalservice.api.push.exceptions.InvalidTransportModeException; import org.whispersystems.signalservice.api.push.exceptions.MalformedRequestException; import org.whispersystems.signalservice.api.push.exceptions.MalformedResponseException; @@ -340,6 +341,184 @@ public class PushServiceSocket { return JsonUtil.fromJson(response, VerifyAccountResponse.class); } + /** + * V2 API: Creates a verification session and returns the raw Response for manual handling. + * Caller is responsible for closing the response. + */ + public Response createVerificationSessionV2(@Nonnull String e164, @Nullable String pushToken, @Nullable String mcc, @Nullable String mnc) throws IOException { + final String jsonBody = JsonUtil.toJson(new VerificationSessionMetadataRequestBody(e164, pushToken, mcc, mnc)); + return makeServiceRequestWithoutValidation(VERIFICATION_SESSION_PATH, "POST", jsonRequestBody(jsonBody), NO_HEADERS, SealedSenderAccess.NONE, false); + } + + /** + * V2 API: Gets session status and returns the raw Response for manual handling. + * Caller is responsible for closing the response. + */ + public Response getSessionStatusV2(String sessionId) throws IOException { + String path = VERIFICATION_SESSION_PATH + "/" + sessionId; + return makeServiceRequestWithoutValidation(path, "GET", jsonRequestBody(null), NO_HEADERS, SealedSenderAccess.NONE, false); + } + + /** + * V2 API: Patches verification session and returns the raw Response for manual handling. + * Caller is responsible for closing the response. + */ + public Response patchVerificationSessionV2(String sessionId, @Nullable String pushToken, @Nullable String mcc, @Nullable String mnc, @Nullable String captchaToken, @Nullable String pushChallengeToken) throws IOException { + String path = VERIFICATION_SESSION_PATH + "/" + sessionId; + final UpdateVerificationSessionRequestBody requestBody = new UpdateVerificationSessionRequestBody(captchaToken, pushToken, pushChallengeToken, mcc, mnc); + return makeServiceRequestWithoutValidation(path, "PATCH", jsonRequestBody(JsonUtil.toJson(requestBody)), NO_HEADERS, SealedSenderAccess.NONE, false); + } + + /** + * V2 API: Requests verification code and returns the raw Response for manual handling. + * Caller is responsible for closing the response. + */ + public Response requestVerificationCodeV2(String sessionId, Locale locale, boolean androidSmsRetriever, VerificationCodeTransport transport) throws IOException { + String path = String.format(VERIFICATION_CODE_PATH, sessionId); + Map headers = locale != null ? Collections.singletonMap("Accept-Language", locale.getLanguage() + "-" + locale.getCountry()) : NO_HEADERS; + Map body = new HashMap<>(); + + switch (transport) { + case SMS: + body.put("transport", "sms"); + break; + case VOICE: + body.put("transport", "voice"); + break; + } + + body.put("client", androidSmsRetriever ? "android-2021-03" : "android"); + + return makeServiceRequestWithoutValidation(path, "POST", jsonRequestBody(JsonUtil.toJson(body)), headers, SealedSenderAccess.NONE, false); + } + + /** + * V2 API: Submits verification code and returns the raw Response for manual handling. + * Caller is responsible for closing the response. + */ + public Response submitVerificationCodeV2(String sessionId, String verificationCode) throws IOException { + String path = String.format(VERIFICATION_CODE_PATH, sessionId); + Map body = new HashMap<>(); + body.put("code", verificationCode); + return makeServiceRequestWithoutValidation(path, "PUT", jsonRequestBody(JsonUtil.toJson(body)), NO_HEADERS, SealedSenderAccess.NONE, false); + } + + /** + * V2 API: Submits registration request and returns the raw Response for manual handling. + * Caller is responsible for closing the response. + */ + public Response submitRegistrationRequestV2(@Nullable String sessionId, @Nullable String recoveryPassword, AccountAttributes attributes, PreKeyCollection aciPreKeys, PreKeyCollection pniPreKeys, @Nullable String fcmToken, boolean skipDeviceTransfer) throws IOException { + String path = REGISTRATION_PATH; + if (sessionId == null && recoveryPassword == null) { + throw new IllegalArgumentException("Neither Session ID nor Recovery Password provided."); + } + + if (sessionId != null && recoveryPassword != null) { + throw new IllegalArgumentException("You must supply one and only one of either: Session ID, or Recovery Password."); + } + + GcmRegistrationId gcmRegistrationId; + if (attributes.getFetchesMessages()) { + gcmRegistrationId = null; + } else { + gcmRegistrationId = new GcmRegistrationId(fcmToken, true); + } + + RegistrationSessionRequestBody body; + try { + final SignedPreKeyEntity aciSignedPreKey = new SignedPreKeyEntity(Objects.requireNonNull(aciPreKeys.getSignedPreKey()).getId(), + aciPreKeys.getSignedPreKey().getKeyPair().getPublicKey(), + aciPreKeys.getSignedPreKey().getSignature()); + final SignedPreKeyEntity pniSignedPreKey = new SignedPreKeyEntity(Objects.requireNonNull(pniPreKeys.getSignedPreKey()).getId(), + pniPreKeys.getSignedPreKey().getKeyPair().getPublicKey(), + pniPreKeys.getSignedPreKey().getSignature()); + final KyberPreKeyEntity aciLastResortKyberPreKey = new KyberPreKeyEntity(Objects.requireNonNull(aciPreKeys.getLastResortKyberPreKey()).getId(), + aciPreKeys.getLastResortKyberPreKey().getKeyPair().getPublicKey(), + aciPreKeys.getLastResortKyberPreKey().getSignature()); + final KyberPreKeyEntity pniLastResortKyberPreKey = new KyberPreKeyEntity(Objects.requireNonNull(pniPreKeys.getLastResortKyberPreKey()).getId(), + pniPreKeys.getLastResortKyberPreKey().getKeyPair().getPublicKey(), + pniPreKeys.getLastResortKyberPreKey().getSignature()); + + body = new RegistrationSessionRequestBody(sessionId, + recoveryPassword, + attributes, + Base64.encodeWithoutPadding(aciPreKeys.getIdentityKey().serialize()), + Base64.encodeWithoutPadding(pniPreKeys.getIdentityKey().serialize()), + aciSignedPreKey, + pniSignedPreKey, + aciLastResortKyberPreKey, + pniLastResortKyberPreKey, + gcmRegistrationId, + skipDeviceTransfer, + true); + } catch (InvalidKeyException e) { + throw new AssertionError("unexpected invalid key", e); + } + + return makeServiceRequestWithoutValidation(path, "POST", jsonRequestBody(JsonUtil.toJson(body)), NO_HEADERS, SealedSenderAccess.NONE, false); + } + + /** + * V2 API: Submits registration request with explicit credentials and returns the raw Response for manual handling. + * Caller is responsible for closing the response. + * + * @param e164 The phone number in E.164 format (used as username for basic auth) + * @param password The password for basic auth + */ + public Response submitRegistrationRequestV2(@Nonnull String e164, @Nonnull String password, @Nullable String sessionId, @Nullable String recoveryPassword, AccountAttributes attributes, PreKeyCollection aciPreKeys, PreKeyCollection pniPreKeys, @Nullable String fcmToken, boolean skipDeviceTransfer) throws IOException { + String path = REGISTRATION_PATH; + if (sessionId == null && recoveryPassword == null) { + throw new IllegalArgumentException("Neither Session ID nor Recovery Password provided."); + } + + if (sessionId != null && recoveryPassword != null) { + throw new IllegalArgumentException("You must supply one and only one of either: Session ID, or Recovery Password."); + } + + GcmRegistrationId gcmRegistrationId; + if (attributes.getFetchesMessages()) { + gcmRegistrationId = null; + } else { + gcmRegistrationId = new GcmRegistrationId(fcmToken, true); + } + + RegistrationSessionRequestBody body; + try { + final SignedPreKeyEntity aciSignedPreKey = new SignedPreKeyEntity(Objects.requireNonNull(aciPreKeys.getSignedPreKey()).getId(), + aciPreKeys.getSignedPreKey().getKeyPair().getPublicKey(), + aciPreKeys.getSignedPreKey().getSignature()); + final SignedPreKeyEntity pniSignedPreKey = new SignedPreKeyEntity(Objects.requireNonNull(pniPreKeys.getSignedPreKey()).getId(), + pniPreKeys.getSignedPreKey().getKeyPair().getPublicKey(), + pniPreKeys.getSignedPreKey().getSignature()); + final KyberPreKeyEntity aciLastResortKyberPreKey = new KyberPreKeyEntity(Objects.requireNonNull(aciPreKeys.getLastResortKyberPreKey()).getId(), + aciPreKeys.getLastResortKyberPreKey().getKeyPair().getPublicKey(), + aciPreKeys.getLastResortKyberPreKey().getSignature()); + final KyberPreKeyEntity pniLastResortKyberPreKey = new KyberPreKeyEntity(Objects.requireNonNull(pniPreKeys.getLastResortKyberPreKey()).getId(), + pniPreKeys.getLastResortKyberPreKey().getKeyPair().getPublicKey(), + pniPreKeys.getLastResortKyberPreKey().getSignature()); + + body = new RegistrationSessionRequestBody(sessionId, + recoveryPassword, + attributes, + Base64.encodeWithoutPadding(aciPreKeys.getIdentityKey().serialize()), + Base64.encodeWithoutPadding(pniPreKeys.getIdentityKey().serialize()), + aciSignedPreKey, + pniSignedPreKey, + aciLastResortKyberPreKey, + pniLastResortKyberPreKey, + gcmRegistrationId, + skipDeviceTransfer, + true); + } catch (InvalidKeyException e) { + throw new AssertionError("unexpected invalid key", e); + } + + String authHeader = "Basic " + Base64.encodeWithPadding((e164 + ":" + password).getBytes("UTF-8")); + Map headers = Collections.singletonMap("Authorization", authHeader); + + return makeServiceRequestWithoutValidation(path, "POST", jsonRequestBody(JsonUtil.toJson(body)), headers, SealedSenderAccess.NONE, false); + } + public void setRestoreMethodChosen(@Nonnull String token, @Nonnull RestoreMethodBody request) throws IOException { String body = JsonUtil.toJson(request); makeServiceRequest(String.format(Locale.US, SET_RESTORE_METHOD_PATH, urlEncode(token)), "PUT", body, NO_HEADERS, UNOPINIONATED_HANDLER, SealedSenderAccess.NONE); @@ -1167,6 +1346,26 @@ public class PushServiceSocket { } } + private Response makeServiceRequestWithoutValidation(String urlFragment, + String method, + RequestBody body, + Map headers, + @Nullable SealedSenderAccess sealedSenderAccess, + boolean doNotAddAuthenticationOrUnidentifiedAccessKey) + throws PushNetworkException + { + Response response = null; + try { + response = getServiceConnection(urlFragment, method, body, headers, sealedSenderAccess, doNotAddAuthenticationOrUnidentifiedAccessKey); + return response; + } catch (Exception e) { + if (response != null && response.body() != null) { + response.body().close(); + } + throw e; + } + } + private Response validateServiceResponse(Response response) throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException { int responseCode = response.code(); @@ -1903,7 +2102,9 @@ public class PushServiceSocket { @Override public void handle(int responseCode, ResponseBody body, Function getHeader) throws NonSuccessfulResponseCodeException, PushNetworkException { - if (responseCode == 403) { + if (responseCode == 400) { + throw new InvalidRegistrationSessionIdException(); + } if (responseCode == 403) { throw new IncorrectRegistrationRecoveryPasswordException(); } else if (responseCode == 404) { throw new NoSuchSessionException(); diff --git a/registration/app/build.gradle.kts b/registration/app/build.gradle.kts new file mode 100644 index 0000000000..b0ee445e12 --- /dev/null +++ b/registration/app/build.gradle.kts @@ -0,0 +1,60 @@ +plugins { + id("signal-sample-app") + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "org.signal.registration.sample" + + defaultConfig { + applicationId = "org.signal.registration.sample" + versionCode = 1 + versionName = "1.0" + + minSdk = 26 + targetSdk = 34 + } + + buildFeatures { + compose = true + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } +} + +dependencies { + lintChecks(project(":lintchecks")) + + // Registration library + implementation(project(":registration")) + + // Core dependencies + implementation(project(":core-ui")) + implementation(project(":core-util")) + implementation(project(":libsignal-service")) + + // libsignal-protocol for PreKeyCollection types + implementation(libs.libsignal.client) + + // Kotlin serialization for JSON parsing + implementation(libs.kotlinx.serialization.json) + + // AndroidX + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.core.ktx) + + // Compose BOM + platform(libs.androidx.compose.bom).let { composeBom -> + implementation(composeBom) + } + + // Compose dependencies + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling.core) +} diff --git a/registration/app/src/main/AndroidManifest.xml b/registration/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..c19ba9ca4d --- /dev/null +++ b/registration/app/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + diff --git a/registration/app/src/main/java/org/signal/registration/sample/MainActivity.kt b/registration/app/src/main/java/org/signal/registration/sample/MainActivity.kt new file mode 100644 index 0000000000..93e2e0b5e7 --- /dev/null +++ b/registration/app/src/main/java/org/signal/registration/sample/MainActivity.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.sample + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.registration.RegistrationActivity + +/** + * Sample app activity that launches the registration flow for testing. + */ +class MainActivity : ComponentActivity() { + + private val registrationLauncher = registerForActivityResult( + RegistrationActivity.RegistrationContract() + ) { success -> + registrationComplete = success + } + + private var registrationComplete by mutableStateOf(false) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + SignalTheme(incognitoKeyboardEnabled = false) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + if (registrationComplete) { + RegistrationCompleteScreen( + onStartOver = { + registrationComplete = false + } + ) + } else { + MainScreen( + onLaunchRegistration = { + registrationLauncher.launch(Unit) + } + ) + } + } + } + } + } +} + +@Composable +private fun MainScreen( + onLaunchRegistration: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Registration Sample App", + style = MaterialTheme.typography.headlineMedium + ) + + Text( + text = "Test the registration flow", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 8.dp) + ) + + Button( + onClick = onLaunchRegistration, + modifier = Modifier + .fillMaxWidth() + .padding(top = 48.dp) + ) { + Text("Start Registration") + } + } +} + +@Composable +private fun RegistrationCompleteScreen( + onStartOver: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Registration Complete!", + style = MaterialTheme.typography.headlineMedium + ) + + Button( + onClick = onStartOver, + modifier = Modifier + .fillMaxWidth() + .padding(top = 48.dp) + ) { + Text("Start Over") + } + } +} diff --git a/registration/app/src/main/java/org/signal/registration/sample/RegistrationApplication.kt b/registration/app/src/main/java/org/signal/registration/sample/RegistrationApplication.kt new file mode 100644 index 0000000000..d59e0768db --- /dev/null +++ b/registration/app/src/main/java/org/signal/registration/sample/RegistrationApplication.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.sample + +import android.app.Application +import android.os.Build +import org.signal.core.util.Base64 +import org.signal.core.util.logging.AndroidLogger +import org.signal.core.util.logging.Log +import org.signal.registration.RegistrationDependencies +import org.signal.registration.sample.dependencies.RealNetworkController +import org.signal.registration.sample.dependencies.RealStorageController +import org.whispersystems.signalservice.api.push.ServiceId.ACI +import org.whispersystems.signalservice.api.push.ServiceId.PNI +import org.whispersystems.signalservice.api.push.TrustStore +import org.whispersystems.signalservice.api.util.CredentialsProvider +import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl +import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl +import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration +import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl +import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl +import org.whispersystems.signalservice.internal.configuration.SignalSvr2Url +import org.whispersystems.signalservice.internal.push.PushServiceSocket +import java.io.InputStream +import java.util.Optional + +class RegistrationApplication : Application() { + + override fun onCreate() { + super.onCreate() + + Log.initialize(AndroidLogger) + + val pushServiceSocket = createPushServiceSocket() + val networkController = RealNetworkController(pushServiceSocket) + val storageController = RealStorageController(this) + + RegistrationDependencies.provide( + RegistrationDependencies( + networkController = networkController, + storageController = storageController + ) + ) + } + + private fun createPushServiceSocket(): PushServiceSocket { + val trustStore = SampleTrustStore() + val configuration = createServiceConfiguration(trustStore) + val credentialsProvider = NoopCredentialsProvider() + val signalAgent = "Signal-Android/${BuildConfig.VERSION_NAME} Android/${Build.VERSION.SDK_INT}" + + return PushServiceSocket( + configuration, + credentialsProvider, + signalAgent, + true // automaticNetworkRetry + ) + } + + private fun createServiceConfiguration(trustStore: TrustStore): SignalServiceConfiguration { + return SignalServiceConfiguration( + signalServiceUrls = arrayOf(SignalServiceUrl("https://chat.staging.signal.org", trustStore)), + signalCdnUrlMap = mapOf( + 0 to arrayOf(SignalCdnUrl("https://cdn-staging.signal.org", trustStore)), + 2 to arrayOf(SignalCdnUrl("https://cdn2-staging.signal.org", trustStore)), + 3 to arrayOf(SignalCdnUrl("https://cdn3-staging.signal.org", trustStore)) + ), + signalStorageUrls = arrayOf(SignalStorageUrl("https://storage-staging.signal.org", trustStore)), + signalCdsiUrls = arrayOf(SignalCdsiUrl("https://cdsi.staging.signal.org", trustStore)), + signalSvr2Urls = arrayOf(SignalSvr2Url("https://svr2.staging.signal.org", trustStore)), + networkInterceptors = emptyList(), + dns = Optional.empty(), + signalProxy = Optional.empty(), + systemHttpProxy = Optional.empty(), + zkGroupServerPublicParams = Base64.decode("ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ=="), + genericServerPublicParams = Base64.decode("AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N"), + backupServerPublicParams = Base64.decode("AHYrGb9IfugAAJiPKp+mdXUx+OL9zBolPYHYQz6GI1gWjpEu5me3zVNSvmYY4zWboZHif+HG1sDHSuvwFd0QszSwuSF4X4kRP3fJREdTZ5MCR0n55zUppTwfHRW2S4sdQ0JGz7YDQIJCufYSKh0pGNEHL6hv79Agrdnr4momr3oXdnkpVBIp3HWAQ6IbXQVSG18X36GaicI1vdT0UFmTwU2KTneluC2eyL9c5ff8PcmiS+YcLzh0OKYQXB5ZfQ06d6DiINvDQLy75zcfUOniLAj0lGJiHxGczin/RXisKSR8"), + censored = false + ) + } + + private inner class SampleTrustStore : TrustStore { + override fun getKeyStoreInputStream(): InputStream { + return resources.openRawResource(R.raw.whisper) + } + + override fun getKeyStorePassword(): String { + return "whisper" + } + } + + private class NoopCredentialsProvider : CredentialsProvider { + override fun getAci(): ACI? = null + override fun getPni(): PNI? = null + override fun getE164(): String? = null + override fun getDeviceId(): Int = 1 + override fun getPassword(): String? = null + } +} diff --git a/registration/app/src/main/java/org/signal/registration/sample/dependencies/RealNetworkController.kt b/registration/app/src/main/java/org/signal/registration/sample/dependencies/RealNetworkController.kt new file mode 100644 index 0000000000..1c8252bdb1 --- /dev/null +++ b/registration/app/src/main/java/org/signal/registration/sample/dependencies/RealNetworkController.kt @@ -0,0 +1,340 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.sample.dependencies + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.Response +import org.signal.registration.NetworkController +import org.signal.registration.NetworkController.AccountAttributes +import org.signal.registration.NetworkController.CreateSessionError +import org.signal.registration.NetworkController.GetSessionStatusError +import org.signal.registration.NetworkController.PreKeyCollection +import org.signal.registration.NetworkController.RegisterAccountError +import org.signal.registration.NetworkController.RegisterAccountResponse +import org.signal.registration.NetworkController.RegistrationLockResponse +import org.signal.registration.NetworkController.RegistrationNetworkResult +import org.signal.registration.NetworkController.RequestVerificationCodeError +import org.signal.registration.NetworkController.SessionMetadata +import org.signal.registration.NetworkController.SubmitVerificationCodeError +import org.signal.registration.NetworkController.ThirdPartyServiceErrorResponse +import org.signal.registration.NetworkController.UpdateSessionError +import org.signal.registration.NetworkController.VerificationCodeTransport +import org.whispersystems.signalservice.internal.push.PushServiceSocket +import java.io.IOException +import java.util.Locale +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import org.whispersystems.signalservice.api.account.AccountAttributes as ServiceAccountAttributes +import org.whispersystems.signalservice.api.account.PreKeyCollection as ServicePreKeyCollection + +class RealNetworkController( + private val pushServiceSocket: PushServiceSocket +) : NetworkController { + + private val json = Json { ignoreUnknownKeys = true } + + override suspend fun createSession( + e164: String, + fcmToken: String?, + mcc: String?, + mnc: String? + ): RegistrationNetworkResult = withContext(Dispatchers.IO) { + try { + pushServiceSocket.createVerificationSessionV2(e164, fcmToken, mcc, mnc).use { response -> + when (response.code) { + 200 -> { + val session = json.decodeFromString(response.body.string()) + RegistrationNetworkResult.Success(session) + } + 400 -> { + RegistrationNetworkResult.Failure(CreateSessionError.InvalidRequest(response.body.string())) + } + 429 -> { + RegistrationNetworkResult.Failure(CreateSessionError.RateLimited(response.retryAfter())) + } + else -> { + RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}")) + } + } + } + } catch (e: IOException) { + RegistrationNetworkResult.NetworkError(e) + } catch (e: Exception) { + RegistrationNetworkResult.ApplicationError(e) + } + } + + override suspend fun getSession(sessionId: String): RegistrationNetworkResult = withContext(Dispatchers.IO) { + try { + pushServiceSocket.getSessionStatusV2(sessionId).use { response -> + when (response.code) { + 200 -> { + val session = json.decodeFromString(response.body.string()) + RegistrationNetworkResult.Success(session) + } + 400 -> { + RegistrationNetworkResult.Failure(GetSessionStatusError.InvalidRequest(response.body.string())) + } + 404 -> { + RegistrationNetworkResult.Failure(GetSessionStatusError.SessionNotFound(response.body.string())) + } + 422 -> { + RegistrationNetworkResult.Failure(GetSessionStatusError.InvalidSessionId(response.body.string())) + } + else -> { + RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}")) + } + } + } + } catch (e: IOException) { + RegistrationNetworkResult.NetworkError(e) + } catch (e: Exception) { + RegistrationNetworkResult.ApplicationError(e) + } + } + + override suspend fun updateSession( + sessionId: String?, + pushChallengeToken: String?, + captchaToken: String? + ): RegistrationNetworkResult = withContext(Dispatchers.IO) { + try { + pushServiceSocket.patchVerificationSessionV2( + sessionId, + null, // pushToken + null, // mcc + null, // mnc + captchaToken, + pushChallengeToken + ).use { response -> + when (response.code) { + 200 -> { + val session = json.decodeFromString(response.body.string()) + RegistrationNetworkResult.Success(session) + } + 400 -> { + RegistrationNetworkResult.Failure(UpdateSessionError.InvalidRequest(response.body.string())) + } + 409 -> { + RegistrationNetworkResult.Failure(UpdateSessionError.RejectedUpdate(response.body.string())) + } + 429 -> { + val session = json.decodeFromString(response.body.string()) + RegistrationNetworkResult.Failure(UpdateSessionError.RateLimited(response.retryAfter(), session)) + } + else -> { + RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}")) + } + } + } + } catch (e: IOException) { + RegistrationNetworkResult.NetworkError(e) + } catch (e: Exception) { + RegistrationNetworkResult.ApplicationError(e) + } + } + + override suspend fun requestVerificationCode( + sessionId: String, + locale: Locale?, + androidSmsRetrieverSupported: Boolean, + transport: VerificationCodeTransport + ): RegistrationNetworkResult = withContext(Dispatchers.IO) { + try { + val socketTransport = when (transport) { + VerificationCodeTransport.SMS -> PushServiceSocket.VerificationCodeTransport.SMS + VerificationCodeTransport.VOICE -> PushServiceSocket.VerificationCodeTransport.VOICE + } + + pushServiceSocket.requestVerificationCodeV2( + sessionId, + locale, + androidSmsRetrieverSupported, + socketTransport + ).use { response -> + when (response.code) { + 200 -> { + val session = json.decodeFromString(response.body.string()) + RegistrationNetworkResult.Success(session) + } + 400 -> { + RegistrationNetworkResult.Failure(RequestVerificationCodeError.InvalidSessionId(response.body.string())) + } + 404 -> { + RegistrationNetworkResult.Failure(RequestVerificationCodeError.SessionNotFound(response.body.string())) + } + 409 -> { + val session = json.decodeFromString(response.body.string()) + RegistrationNetworkResult.Failure(RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified(session)) + } + 418 -> { + val session = json.decodeFromString(response.body.string()) + RegistrationNetworkResult.Failure(RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport(session)) + } + 429 -> { + val session = json.decodeFromString(response.body.string()) + RegistrationNetworkResult.Failure(RequestVerificationCodeError.RateLimited(response.retryAfter(), session)) + } + 440 -> { + val errorBody = json.decodeFromString(response.body.string()) + RegistrationNetworkResult.Failure(RequestVerificationCodeError.ThirdPartyServiceError(errorBody)) + } + else -> { + RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}")) + } + } + } + } catch (e: IOException) { + RegistrationNetworkResult.NetworkError(e) + } catch (e: Exception) { + RegistrationNetworkResult.ApplicationError(e) + } + } + + override suspend fun submitVerificationCode( + sessionId: String, + verificationCode: String + ): RegistrationNetworkResult = withContext(Dispatchers.IO) { + try { + pushServiceSocket.submitVerificationCodeV2(sessionId, verificationCode).use { response -> + when (response.code) { + 200 -> { + val session = json.decodeFromString(response.body.string()) + RegistrationNetworkResult.Success(session) + } + 400 -> { + RegistrationNetworkResult.Failure(SubmitVerificationCodeError.IncorrectVerificationCode(response.body.string())) + } + 404 -> { + RegistrationNetworkResult.Failure(SubmitVerificationCodeError.SessionNotFound(response.body.string())) + } + 409 -> { + val session = json.decodeFromString(response.body.string()) + RegistrationNetworkResult.Failure(SubmitVerificationCodeError.SessionAlreadyVerifiedOrNoCodeRequested(session)) + } + 429 -> { + val session = json.decodeFromString(response.body.string()) + RegistrationNetworkResult.Failure(SubmitVerificationCodeError.RateLimited(response.retryAfter(), session)) + } + else -> { + RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}")) + } + } + } + } catch (e: IOException) { + RegistrationNetworkResult.NetworkError(e) + } catch (e: Exception) { + RegistrationNetworkResult.ApplicationError(e) + } + } + + override suspend fun registerAccount( + e164: String, + password: String, + sessionId: String?, + recoveryPassword: String?, + attributes: AccountAttributes, + aciPreKeys: PreKeyCollection, + pniPreKeys: PreKeyCollection, + fcmToken: String?, + skipDeviceTransfer: Boolean + ): RegistrationNetworkResult = withContext(Dispatchers.IO) { + try { + val serviceAttributes = attributes.toServiceAccountAttributes() + val serviceAciPreKeys = aciPreKeys.toServicePreKeyCollection() + val servicePniPreKeys = pniPreKeys.toServicePreKeyCollection() + + pushServiceSocket.submitRegistrationRequestV2( + e164, + password, + sessionId, + recoveryPassword, + serviceAttributes, + serviceAciPreKeys, + servicePniPreKeys, + fcmToken, + skipDeviceTransfer + ).use { response -> + when (response.code) { + 200 -> { + val result = json.decodeFromString(response.body.string()) + RegistrationNetworkResult.Success(result) + } + 403 -> { + RegistrationNetworkResult.Failure(RegisterAccountError.RegistrationRecoveryPasswordIncorrect(response.body.string())) + } + 409 -> { + RegistrationNetworkResult.Failure(RegisterAccountError.DeviceTransferPossible) + } + 422 -> { + RegistrationNetworkResult.Failure(RegisterAccountError.InvalidRequest(response.body.string())) + } + 423 -> { + val lockResponse = json.decodeFromString(response.body.string()) + RegistrationNetworkResult.Failure(RegisterAccountError.RegistrationLock(lockResponse)) + } + 429 -> { + RegistrationNetworkResult.Failure(RegisterAccountError.RateLimited(response.retryAfter())) + } + else -> { + RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}")) + } + } + } + } catch (e: IOException) { + RegistrationNetworkResult.NetworkError(e) + } catch (e: Exception) { + RegistrationNetworkResult.ApplicationError(e) + } + } + + override suspend fun getFcmToken(): String? { + return null + } + + override fun getCaptchaUrl(): String { + return "https://signalcaptchas.org/staging/registration/generate.html" + } + + private fun AccountAttributes.toServiceAccountAttributes(): ServiceAccountAttributes { + return ServiceAccountAttributes( + signalingKey, + registrationId, + fetchesMessages, + registrationLock, + unidentifiedAccessKey, + unrestrictedUnidentifiedAccess, + capabilities?.toServiceCapabilities(), + discoverableByPhoneNumber, + name, + pniRegistrationId, + recoveryPassword + ) + } + + private fun AccountAttributes.Capabilities.toServiceCapabilities(): ServiceAccountAttributes.Capabilities { + return ServiceAccountAttributes.Capabilities( + storage, + versionedExpirationTimer, + attachmentBackfill, + spqr + ) + } + + private fun PreKeyCollection.toServicePreKeyCollection(): ServicePreKeyCollection { + return ServicePreKeyCollection( + identityKey = identityKey, + signedPreKey = signedPreKey, + lastResortKyberPreKey = lastResortKyberPreKey + ) + } + + private fun Response.retryAfter(): Duration { + return this.header("Retry-After")?.toLongOrNull()?.seconds ?: 0.seconds + } +} diff --git a/registration/app/src/main/java/org/signal/registration/sample/dependencies/RealStorageController.kt b/registration/app/src/main/java/org/signal/registration/sample/dependencies/RealStorageController.kt new file mode 100644 index 0000000000..674fd68648 --- /dev/null +++ b/registration/app/src/main/java/org/signal/registration/sample/dependencies/RealStorageController.kt @@ -0,0 +1,322 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.sample.dependencies + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.signal.core.util.Base64 +import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.protocol.ecc.ECKeyPair +import org.signal.libsignal.protocol.kem.KEMKeyPair +import org.signal.libsignal.protocol.kem.KEMKeyType +import org.signal.libsignal.protocol.state.KyberPreKeyRecord +import org.signal.libsignal.protocol.state.SignedPreKeyRecord +import org.signal.libsignal.zkgroup.profiles.ProfileKey +import org.signal.registration.KeyMaterial +import org.signal.registration.StorageController +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * Implementation of [StorageController] that persists registration data to a SQLite database. + */ +class RealStorageController(context: Context) : StorageController { + + private val db: RegistrationDatabase = RegistrationDatabase(context) + + override suspend fun generateAndStoreKeyMaterial(): KeyMaterial = withContext(Dispatchers.IO) { + val aciIdentityKeyPair = IdentityKeyPair.generate() + val pniIdentityKeyPair = IdentityKeyPair.generate() + + val aciSignedPreKeyId = generatePreKeyId() + val pniSignedPreKeyId = generatePreKeyId() + val aciKyberPreKeyId = generatePreKeyId() + val pniKyberPreKeyId = generatePreKeyId() + + val timestamp = System.currentTimeMillis() + + val aciSignedPreKey = generateSignedPreKey(aciSignedPreKeyId, timestamp, aciIdentityKeyPair) + val pniSignedPreKey = generateSignedPreKey(pniSignedPreKeyId, timestamp, pniIdentityKeyPair) + val aciLastResortKyberPreKey = generateKyberPreKey(aciKyberPreKeyId, timestamp, aciIdentityKeyPair) + val pniLastResortKyberPreKey = generateKyberPreKey(pniKyberPreKeyId, timestamp, pniIdentityKeyPair) + + val aciRegistrationId = generateRegistrationId() + val pniRegistrationId = generateRegistrationId() + val profileKey = generateProfileKey() + val unidentifiedAccessKey = deriveUnidentifiedAccessKey(profileKey) + val password = generatePassword() + + val keyMaterial = KeyMaterial( + aciIdentityKeyPair = aciIdentityKeyPair, + aciSignedPreKey = aciSignedPreKey, + aciLastResortKyberPreKey = aciLastResortKyberPreKey, + pniIdentityKeyPair = pniIdentityKeyPair, + pniSignedPreKey = pniSignedPreKey, + pniLastResortKyberPreKey = pniLastResortKyberPreKey, + aciRegistrationId = aciRegistrationId, + pniRegistrationId = pniRegistrationId, + unidentifiedAccessKey = unidentifiedAccessKey, + servicePassword = password + ) + + storeKeyMaterial(keyMaterial, profileKey) + + keyMaterial + } + + private fun storeKeyMaterial(keyMaterial: KeyMaterial, profileKey: ProfileKey) { + val database = db.writableDatabase + database.beginTransaction() + try { + // Clear any existing data + database.delete(RegistrationDatabase.TABLE_IDENTITY_KEYS, null, null) + database.delete(RegistrationDatabase.TABLE_SIGNED_PREKEYS, null, null) + database.delete(RegistrationDatabase.TABLE_KYBER_PREKEYS, null, null) + database.delete(RegistrationDatabase.TABLE_REGISTRATION_IDS, null, null) + database.delete(RegistrationDatabase.TABLE_PROFILE_KEY, null, null) + + // Store ACI identity key + database.insert( + RegistrationDatabase.TABLE_IDENTITY_KEYS, + null, + ContentValues().apply { + put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_ACI) + put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.aciIdentityKeyPair.serialize()) + } + ) + + // Store PNI identity key + database.insert( + RegistrationDatabase.TABLE_IDENTITY_KEYS, + null, + ContentValues().apply { + put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_PNI) + put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.pniIdentityKeyPair.serialize()) + } + ) + + // Store ACI signed pre-key + database.insert( + RegistrationDatabase.TABLE_SIGNED_PREKEYS, + null, + ContentValues().apply { + put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_ACI) + put(RegistrationDatabase.COLUMN_KEY_ID, keyMaterial.aciSignedPreKey.id) + put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.aciSignedPreKey.serialize()) + } + ) + + // Store PNI signed pre-key + database.insert( + RegistrationDatabase.TABLE_SIGNED_PREKEYS, + null, + ContentValues().apply { + put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_PNI) + put(RegistrationDatabase.COLUMN_KEY_ID, keyMaterial.pniSignedPreKey.id) + put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.pniSignedPreKey.serialize()) + } + ) + + // Store ACI Kyber pre-key + database.insert( + RegistrationDatabase.TABLE_KYBER_PREKEYS, + null, + ContentValues().apply { + put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_ACI) + put(RegistrationDatabase.COLUMN_KEY_ID, keyMaterial.aciLastResortKyberPreKey.id) + put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.aciLastResortKyberPreKey.serialize()) + } + ) + + // Store PNI Kyber pre-key + database.insert( + RegistrationDatabase.TABLE_KYBER_PREKEYS, + null, + ContentValues().apply { + put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_PNI) + put(RegistrationDatabase.COLUMN_KEY_ID, keyMaterial.pniLastResortKyberPreKey.id) + put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.pniLastResortKyberPreKey.serialize()) + } + ) + + // Store ACI registration ID + database.insert( + RegistrationDatabase.TABLE_REGISTRATION_IDS, + null, + ContentValues().apply { + put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_ACI) + put(RegistrationDatabase.COLUMN_REGISTRATION_ID, keyMaterial.aciRegistrationId) + } + ) + + // Store PNI registration ID + database.insert( + RegistrationDatabase.TABLE_REGISTRATION_IDS, + null, + ContentValues().apply { + put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_PNI) + put(RegistrationDatabase.COLUMN_REGISTRATION_ID, keyMaterial.pniRegistrationId) + } + ) + + // Store profile key + database.insert( + RegistrationDatabase.TABLE_PROFILE_KEY, + null, + ContentValues().apply { + put(RegistrationDatabase.COLUMN_KEY_DATA, profileKey.serialize()) + } + ) + + database.setTransactionSuccessful() + } finally { + database.endTransaction() + } + } + + private fun generateSignedPreKey(id: Int, timestamp: Long, identityKeyPair: IdentityKeyPair): SignedPreKeyRecord { + val keyPair = ECKeyPair.generate() + val signature = identityKeyPair.privateKey.calculateSignature(keyPair.publicKey.serialize()) + return SignedPreKeyRecord(id, timestamp, keyPair, signature) + } + + private fun generateKyberPreKey(id: Int, timestamp: Long, identityKeyPair: IdentityKeyPair): KyberPreKeyRecord { + val kemKeyPair = KEMKeyPair.generate(KEMKeyType.KYBER_1024) + val signature = identityKeyPair.privateKey.calculateSignature(kemKeyPair.publicKey.serialize()) + return KyberPreKeyRecord(id, timestamp, kemKeyPair, signature) + } + + private fun generatePreKeyId(): Int { + return SecureRandom().nextInt(Int.MAX_VALUE - 1) + 1 + } + + private fun generateRegistrationId(): Int { + return SecureRandom().nextInt(16380) + 1 + } + + private fun generateProfileKey(): ProfileKey { + val keyBytes = ByteArray(32) + SecureRandom().nextBytes(keyBytes) + return ProfileKey(keyBytes) + } + + /** + * Generates a password for basic auth during registration. + * 18 random bytes, base64 encoded with padding. + */ + private fun generatePassword(): String { + val passwordBytes = ByteArray(18) + SecureRandom().nextBytes(passwordBytes) + return Base64.encodeWithPadding(passwordBytes) + } + + /** + * Derives the unidentified access key from a profile key. + * This mirrors the logic in UnidentifiedAccess.deriveAccessKeyFrom(). + */ + private fun deriveUnidentifiedAccessKey(profileKey: ProfileKey): ByteArray { + val nonce = ByteArray(12) + val input = ByteArray(16) + + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(profileKey.serialize(), "AES"), GCMParameterSpec(128, nonce)) + + val ciphertext = cipher.doFinal(input) + return ciphertext.copyOf(16) + } + + companion object { + private const val ACCOUNT_TYPE_ACI = "aci" + private const val ACCOUNT_TYPE_PNI = "pni" + } + + private class RegistrationDatabase(context: Context) : SQLiteOpenHelper( + context, + DATABASE_NAME, + null, + DATABASE_VERSION + ) { + companion object { + const val DATABASE_NAME = "registration.db" + const val DATABASE_VERSION = 1 + + const val TABLE_IDENTITY_KEYS = "identity_keys" + const val TABLE_SIGNED_PREKEYS = "signed_prekeys" + const val TABLE_KYBER_PREKEYS = "kyber_prekeys" + const val TABLE_REGISTRATION_IDS = "registration_ids" + const val TABLE_PROFILE_KEY = "profile_key" + + const val COLUMN_ID = "_id" + const val COLUMN_ACCOUNT_TYPE = "account_type" + const val COLUMN_KEY_ID = "key_id" + const val COLUMN_KEY_DATA = "key_data" + const val COLUMN_REGISTRATION_ID = "registration_id" + } + + override fun onCreate(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE $TABLE_IDENTITY_KEYS ( + $COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, + $COLUMN_ACCOUNT_TYPE TEXT NOT NULL UNIQUE, + $COLUMN_KEY_DATA BLOB NOT NULL + ) + """.trimIndent() + ) + + db.execSQL( + """ + CREATE TABLE $TABLE_SIGNED_PREKEYS ( + $COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, + $COLUMN_ACCOUNT_TYPE TEXT NOT NULL, + $COLUMN_KEY_ID INTEGER NOT NULL, + $COLUMN_KEY_DATA BLOB NOT NULL + ) + """.trimIndent() + ) + + db.execSQL( + """ + CREATE TABLE $TABLE_KYBER_PREKEYS ( + $COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, + $COLUMN_ACCOUNT_TYPE TEXT NOT NULL, + $COLUMN_KEY_ID INTEGER NOT NULL, + $COLUMN_KEY_DATA BLOB NOT NULL + ) + """.trimIndent() + ) + + db.execSQL( + """ + CREATE TABLE $TABLE_REGISTRATION_IDS ( + $COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, + $COLUMN_ACCOUNT_TYPE TEXT NOT NULL UNIQUE, + $COLUMN_REGISTRATION_ID INTEGER NOT NULL + ) + """.trimIndent() + ) + + db.execSQL( + """ + CREATE TABLE $TABLE_PROFILE_KEY ( + $COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, + $COLUMN_KEY_DATA BLOB NOT NULL + ) + """.trimIndent() + ) + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + // No migrations needed yet + } + } +} diff --git a/registration/app/src/main/res/raw/whisper.store b/registration/app/src/main/res/raw/whisper.store new file mode 100644 index 0000000000..1ecadb081e Binary files /dev/null and b/registration/app/src/main/res/raw/whisper.store differ diff --git a/registration/lib/build.gradle.kts b/registration/lib/build.gradle.kts new file mode 100644 index 0000000000..c4f6ea1e2e --- /dev/null +++ b/registration/lib/build.gradle.kts @@ -0,0 +1,71 @@ +plugins { + id("signal-library") + id("kotlin-parcelize") + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlinx.serialization) +} + +android { + namespace = "org.signal.registration" + + buildFeatures { + compose = true + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +dependencies { + implementation(libs.androidx.ui.test.junit4) + lintChecks(project(":lintchecks")) + + // Project dependencies + implementation(project(":core-ui")) + implementation(project(":core-util")) + implementation(libs.libsignal.android) + + // Compose BOM + platform(libs.androidx.compose.bom).let { composeBom -> + implementation(composeBom) + androidTestImplementation(composeBom) + } + + // Compose dependencies + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling.core) + + // Navigation 3 + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) + + // Kotlinx Serialization + implementation(libs.kotlinx.serialization.json) + + // Lifecycle + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) + + // Permissions + implementation(libs.accompanist.permissions) + + // Phone number formatting + implementation(libs.google.libphonenumber) + + // Testing + testImplementation(testLibs.junit.junit) + testImplementation(testLibs.mockk) + testImplementation(testLibs.assertk) + testImplementation(testLibs.kotlinx.coroutines.test) + testImplementation(testLibs.robolectric.robolectric) + testImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(testLibs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + implementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/registration/lib/src/main/AndroidManifest.xml b/registration/lib/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..c4ff6886ba --- /dev/null +++ b/registration/lib/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + diff --git a/registration/lib/src/main/java/org/signal/registration/NetworkController.kt b/registration/lib/src/main/java/org/signal/registration/NetworkController.kt new file mode 100644 index 0000000000..055210fe0a --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/NetworkController.kt @@ -0,0 +1,279 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.signal.core.util.serialization.ByteArrayToBase64Serializer +import org.signal.libsignal.protocol.IdentityKey +import org.signal.libsignal.protocol.state.KyberPreKeyRecord +import org.signal.libsignal.protocol.state.SignedPreKeyRecord +import java.io.IOException +import java.util.Locale +import kotlin.time.Duration + +interface NetworkController { + + /** + * Request that the service initialize a new registration session. + * + * `POST /v1/verification/session` + */ + suspend fun createSession(e164: String, fcmToken: String?, mcc: String?, mnc: String?): RegistrationNetworkResult + + /** + * Retrieve current status of a registration session. + * + * `GET /v1/verification/session/{session-id}` + */ + suspend fun getSession(sessionId: String): RegistrationNetworkResult + + /** + * Update the session with new information. + * + * `PATCH /v1/verification/session/{session-id}` + */ + suspend fun updateSession(sessionId: String?, pushChallengeToken: String?, captchaToken: String?): RegistrationNetworkResult + + /** + * Request an SMS verification code. On success, the server will send an SMS verification code to this Signal user. + * + * `POST /v1/verification/session/{session-id}/code` + * + * @param androidSmsRetrieverSupported whether the system framework will automatically parse the incoming verification message. + */ + suspend fun requestVerificationCode( + sessionId: String, + locale: Locale?, + androidSmsRetrieverSupported: Boolean, + transport: VerificationCodeTransport + ): RegistrationNetworkResult + + /** + * Submit a verification code sent by the service via one of the supported channels (SMS, phone call) to prove the registrant's control of the phone number. + * + * `PUT /v1/verification/session/{session-id}/code` + */ + suspend fun submitVerificationCode(sessionId: String, verificationCode: String): RegistrationNetworkResult + + /** + * Officially register an account. + * Must provide one of ([sessionId], [recoveryPassword]), but not both. + * + * `POST /v1/registration` + * + * @param e164 The phone number in E.164 format (used as username for basic auth) + * @param password The password for basic auth + */ + suspend fun registerAccount( + e164: String, + password: String, + sessionId: String?, + recoveryPassword: String?, + attributes: AccountAttributes, + aciPreKeys: PreKeyCollection, + pniPreKeys: PreKeyCollection, + fcmToken: String?, + skipDeviceTransfer: Boolean + ): RegistrationNetworkResult + + /** + * Retrieves an FCM token, if possible. Null means that this device does not support FCM. + */ + suspend fun getFcmToken(): String? + + /** + * Returns the URL to load in the WebView for captcha verification. + */ + fun getCaptchaUrl(): String + + // TODO +// /** +// * Validates the provided SVR2 auth credentials, returning information on their usability. +// * +// * `POST /v2/svr/auth/check` +// */ +// suspend fun validateSvr2AuthCredential(e164: String, usernamePasswords: List) +// +// /** +// * Validates the provided SVR3 auth credentials, returning information on their usability. +// * +// * `POST /v3/backup/auth/check` +// */ +// suspend fun validateSvr3AuthCredential(e164: String, usernamePasswords: List) +// +// /** +// * Set [RestoreMethod] enum on the server for use by the old device to update UX. +// */ +// suspend fun setRestoreMethod(token: String, method: RestoreMethod) +// +// /** +// * Registers a device as a linked device on a pre-existing account. +// * +// * `PUT /v1/devices/link` +// * +// * - 403: Incorrect account verification +// * - 409: Device missing required account capability +// * - 411: Account reached max number of linked devices +// * - 422: Request is invalid +// * - 429: Rate limited +// */ +// suspend fun registerAsSecondaryDevice(verificationCode: String, attributes: AccountAttributes, aciPreKeys: PreKeyCollection, pniPreKeys: PreKeyCollection, fcmToken: String?) + + sealed interface RegistrationNetworkResult { + data class Success(val data: T) : RegistrationNetworkResult + data class Failure(val error: T) : RegistrationNetworkResult + data class NetworkError(val exception: IOException) : RegistrationNetworkResult + data class ApplicationError(val exception: Throwable) : RegistrationNetworkResult + } + + sealed class CreateSessionError() { + data class InvalidRequest(val message: String) : CreateSessionError() + data class RateLimited(val retryAfter: Duration) : CreateSessionError() + } + + sealed class GetSessionStatusError() { + data class InvalidSessionId(val message: String) : GetSessionStatusError() + data class SessionNotFound(val message: String) : GetSessionStatusError() + data class InvalidRequest(val message: String) : GetSessionStatusError() + } + + sealed class UpdateSessionError() { + data class RejectedUpdate(val message: String) : UpdateSessionError() + data class InvalidRequest(val message: String) : UpdateSessionError() + data class RateLimited(val retryAfter: Duration, val session: SessionMetadata) : UpdateSessionError() + } + + sealed class RequestVerificationCodeError() { + data class InvalidSessionId(val message: String) : RequestVerificationCodeError() + data class SessionNotFound(val message: String) : RequestVerificationCodeError() + data class MissingRequestInformationOrAlreadyVerified(val session: SessionMetadata) : RequestVerificationCodeError() + data class CouldNotFulfillWithRequestedTransport(val session: SessionMetadata) : RequestVerificationCodeError() + data class InvalidRequest(val message: String) : RequestVerificationCodeError() + data class RateLimited(val retryAfter: Duration, val session: SessionMetadata) : RequestVerificationCodeError() + data class ThirdPartyServiceError(val data: ThirdPartyServiceErrorResponse) : RequestVerificationCodeError() + } + + sealed class SubmitVerificationCodeError() { + data class IncorrectVerificationCode(val message: String) : SubmitVerificationCodeError() + data class SessionNotFound(val message: String) : SubmitVerificationCodeError() + data class SessionAlreadyVerifiedOrNoCodeRequested(val session: SessionMetadata) : SubmitVerificationCodeError() + data class RateLimited(val retryAfter: Duration, val session: SessionMetadata) : SubmitVerificationCodeError() + } + + sealed class RegisterAccountError() { + data class RegistrationRecoveryPasswordIncorrect(val message: String) : RegisterAccountError() + data object DeviceTransferPossible : RegisterAccountError() + data class InvalidRequest(val message: String) : RegisterAccountError() + data class RegistrationLock(val data: RegistrationLockResponse) : RegisterAccountError() + data class RateLimited(val retryAfter: Duration) : RegisterAccountError() + } + + @Serializable + @Parcelize + data class SessionMetadata( + val id: String, + val nextSms: Long?, + val nextCall: Long?, + val nextVerificationAttempt: Long?, + val allowedToRequestCode: Boolean, + val requestedInformation: List, + val verified: Boolean + ) : Parcelable + + @Serializable + class AccountAttributes( + val signalingKey: String?, + val registrationId: Int, + val voice: Boolean = true, + val video: Boolean = true, + val fetchesMessages: Boolean, + val registrationLock: String?, + @Serializable(with = ByteArrayToBase64Serializer::class) + val unidentifiedAccessKey: ByteArray?, + val unrestrictedUnidentifiedAccess: Boolean, + val discoverableByPhoneNumber: Boolean, + val capabilities: Capabilities?, + val name: String?, + val pniRegistrationId: Int, + val recoveryPassword: String? + ) { + + @Serializable + data class Capabilities( + val storage: Boolean, + val versionedExpirationTimer: Boolean, + val attachmentBackfill: Boolean, + val spqr: Boolean + ) + } + + @Serializable + @Parcelize + data class RegisterAccountResponse( + @SerialName("uuid") val aci: String, + val pni: String, + @SerialName("number") val e164: String, + val usernameHash: String?, + val usernameLinkHandle: String?, + val storageCapable: Boolean, + val entitlements: Entitlements?, + val reregistration: Boolean + ) : Parcelable { + @Serializable + @Parcelize + data class Entitlements( + val badges: List, + val backup: Backup? + ) : Parcelable + + @Serializable + @Parcelize + data class Badge( + val id: String, + val expirationSeconds: Long, + val visible: Boolean + ) : Parcelable + + @Serializable + @Parcelize + data class Backup( + val backupLevel: Long, + val expirationSeconds: Long + ) : Parcelable + } + + @Serializable + data class RegistrationLockResponse( + val timeRemaining: Long, + val svr2Credentials: SvrCredentials + ) { + + @Serializable + data class SvrCredentials( + val username: String, + val password: String + ) + } + + @Serializable + data class ThirdPartyServiceErrorResponse( + val reason: String, + val permanentFailure: Boolean + ) + + data class PreKeyCollection( + val identityKey: IdentityKey, + val signedPreKey: SignedPreKeyRecord, + val lastResortKyberPreKey: KyberPreKeyRecord + ) + + enum class VerificationCodeTransport { + SMS, VOICE + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/RegistrationActivity.kt b/registration/lib/src/main/java/org/signal/registration/RegistrationActivity.kt new file mode 100644 index 0000000000..89d674c3de --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/RegistrationActivity.kt @@ -0,0 +1,99 @@ +package org.signal.registration + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.viewModels +import androidx.compose.material3.Surface +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.registration.screens.RegistrationHostScreen + +/** + * Activity entry point for the registration flow. + * + * This activity can be launched from the main app to start the registration process. + * Upon successful completion, it will return RESULT_OK. + */ +class RegistrationActivity : ComponentActivity() { + + private val repository: RegistrationRepository by lazy { + RegistrationRepository( + networkController = RegistrationDependencies.get().networkController, + storageController = RegistrationDependencies.get().storageController + ) + } + + private val viewModel: RegistrationViewModel by viewModels(factoryProducer = { + RegistrationViewModel.Factory( + repository = repository + ) + }) + + @OptIn(ExperimentalPermissionsApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val permissionsState = rememberMultiplePermissionsState( + permissions = viewModel.getRequiredPermissions() + ) + + SignalTheme(incognitoKeyboardEnabled = false) { + Surface { + RegistrationHostScreen( + registrationRepository = repository, + viewModel = viewModel, + permissionsState = permissionsState, + onRegistrationComplete = { + setResult(RESULT_OK) + finish() + } + ) + } + } + } + } + + companion object { + /** + * Creates an intent to launch the RegistrationActivity. + * + * @param context The context used to create the intent. + * @return An intent that can be used to start the RegistrationActivity. + */ + fun createIntent(context: Context): Intent { + return Intent(context, RegistrationActivity::class.java) + } + } + + /** + * Activity result contract for launching the registration flow. + * + * Usage: + * ``` + * val registrationLauncher = registerForActivityResult(RegistrationContract()) { success -> + * if (success) { + * // Registration completed successfully + * } else { + * // Registration was cancelled or failed + * } + * } + * + * registrationLauncher.launch(Unit) + * ``` + */ + class RegistrationContract : ActivityResultContract() { + override fun createIntent(context: Context, input: Unit): Intent { + return createIntent(context) + } + + override fun parseResult(resultCode: Int, intent: Intent?): Boolean { + return resultCode == RESULT_OK + } + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/RegistrationDependencies.kt b/registration/lib/src/main/java/org/signal/registration/RegistrationDependencies.kt new file mode 100644 index 0000000000..aa2ac3cd2c --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/RegistrationDependencies.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration + +/** + * Injection point for dependencies needed by this module. + */ +class RegistrationDependencies( + val networkController: NetworkController, + val storageController: StorageController +) { + companion object { + lateinit var dependencies: RegistrationDependencies + + fun provide(registrationDependencies: RegistrationDependencies) { + dependencies = registrationDependencies + } + + fun get(): RegistrationDependencies = dependencies + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/RegistrationFlowEvent.kt b/registration/lib/src/main/java/org/signal/registration/RegistrationFlowEvent.kt new file mode 100644 index 0000000000..5fc9378b7f --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/RegistrationFlowEvent.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration + +sealed interface RegistrationFlowEvent { + data class NavigateToScreen(val route: RegistrationRoute) : RegistrationFlowEvent + data object NavigateBack : RegistrationFlowEvent + data object ResetState : RegistrationFlowEvent + data class SessionUpdated(val session: NetworkController.SessionMetadata) : RegistrationFlowEvent + data class E164Chosen(val e164: String) : RegistrationFlowEvent +} diff --git a/registration/lib/src/main/java/org/signal/registration/RegistrationFlowState.kt b/registration/lib/src/main/java/org/signal/registration/RegistrationFlowState.kt new file mode 100644 index 0000000000..a1a6614ebb --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/RegistrationFlowState.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class RegistrationFlowState( + val backStack: List = listOf(RegistrationRoute.Welcome), + val sessionMetadata: NetworkController.SessionMetadata? = null, + val sessionE164: String? = null +) : Parcelable diff --git a/registration/lib/src/main/java/org/signal/registration/RegistrationNavigation.kt b/registration/lib/src/main/java/org/signal/registration/RegistrationNavigation.kt new file mode 100644 index 0000000000..29533b4d7c --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -0,0 +1,372 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration + +import android.os.Parcelable +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.MultiplePermissionsState +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import org.signal.core.ui.navigation.ResultEffect +import org.signal.registration.screens.captcha.CaptchaScreen +import org.signal.registration.screens.captcha.CaptchaScreenEvents +import org.signal.registration.screens.captcha.CaptchaState +import org.signal.registration.screens.permissions.PermissionsScreen +import org.signal.registration.screens.phonenumber.PhoneNumberEntryScreenEvents +import org.signal.registration.screens.phonenumber.PhoneNumberEntryViewModel +import org.signal.registration.screens.phonenumber.PhoneNumberScreen +import org.signal.registration.screens.pincreation.PinCreationScreen +import org.signal.registration.screens.pincreation.PinCreationScreenEvents +import org.signal.registration.screens.pincreation.PinCreationState +import org.signal.registration.screens.restore.RestoreViaQrScreen +import org.signal.registration.screens.restore.RestoreViaQrScreenEvents +import org.signal.registration.screens.restore.RestoreViaQrState +import org.signal.registration.screens.verificationcode.VerificationCodeScreen +import org.signal.registration.screens.verificationcode.VerificationCodeViewModel +import org.signal.registration.screens.welcome.WelcomeScreen +import org.signal.registration.screens.welcome.WelcomeScreenEvents + +/** + * Navigation routes for the registration flow. + * Using @Serializable and NavKey for type-safe navigation with Navigation 3. + */ +@Parcelize +sealed interface RegistrationRoute : NavKey, Parcelable { + @Serializable + data object Welcome : RegistrationRoute + + @Serializable + data class Permissions(val forRestore: Boolean = false) : RegistrationRoute + + @Serializable + data object PhoneNumberEntry : RegistrationRoute + + @Serializable + data object CountryCodePicker : RegistrationRoute + + @Serializable + data class VerificationCodeEntry(val session: NetworkController.SessionMetadata, val e164: String) : RegistrationRoute + + @Serializable + data class Captcha(val session: NetworkController.SessionMetadata) : RegistrationRoute + + @Serializable + data object Profile : RegistrationRoute + + @Serializable + data object PinSetup : RegistrationRoute + + @Serializable + data object Restore : RegistrationRoute + + @Serializable + data object RestoreViaQr : RegistrationRoute + + @Serializable + data object Transfer : RegistrationRoute + + @Serializable + data class FullyComplete(val registeredData: NetworkController.RegisterAccountResponse) : RegistrationRoute +} + +private const val CAPTCHA_RESULT = "captcha_token" + +/** + * Sets up the navigation graph for the registration flow using Navigation 3. + * + * @param registrationViewModel The shared ViewModel for the registration flow. + * @param permissionsState The permissions state managed at the activity level. + * @param modifier Modifier to be applied to the NavDisplay. + * @param onRegistrationComplete Callback invoked when registration is successfully completed. + */ +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun RegistrationNavHost( + registrationRepository: RegistrationRepository, + registrationViewModel: RegistrationViewModel, + permissionsState: MultiplePermissionsState, + modifier: Modifier = Modifier, + onRegistrationComplete: () -> Unit = {} +) { + val registrationState by registrationViewModel.state.collectAsStateWithLifecycle() + val navigator = remember { RegistrationNavigator(eventEmitter = registrationViewModel::onEvent) } + + val entryProvider = entryProvider { + registrationEntries( + registrationRepository = registrationRepository, + registrationViewModel = registrationViewModel, + permissionsState = permissionsState, + navigator = navigator, + onRegistrationComplete = onRegistrationComplete + ) + } + + val decorators = listOf( + rememberSaveableStateHolderNavEntryDecorator() + ) + + val entries = rememberDecoratedNavEntries( + backStack = registrationState.backStack, + entryDecorators = decorators, + entryProvider = entryProvider + ) + + NavDisplay( + entries = entries, + onBack = { registrationViewModel.onEvent(RegistrationFlowEvent.NavigateBack) }, + modifier = modifier, + transitionSpec = { + // Slide in from right and fade in when navigating forward + ( + slideInHorizontally( + initialOffsetX = { it }, + animationSpec = tween(200) + ) + fadeIn(animationSpec = tween(200)) + ) togetherWith + // Slide out to left and fade out + ( + slideOutHorizontally( + targetOffsetX = { -it }, + animationSpec = tween(200) + ) + fadeOut(animationSpec = tween(200)) + ) + }, + popTransitionSpec = { + // Slide in from left and fade in when navigating back + ( + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(200) + ) + fadeIn(animationSpec = tween(200)) + ) togetherWith + // Slide out to right and fade out + ( + slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(200) + ) + fadeOut(animationSpec = tween(200)) + ) + }, + predictivePopTransitionSpec = { + // Same as popTransitionSpec for predictive back gestures + ( + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(200) + ) + fadeIn(animationSpec = tween(200)) + ) togetherWith + ( + slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(200) + ) + fadeOut(animationSpec = tween(200)) + ) + } + ) +} + +/** + * Defines all navigation entries for the registration flow. + */ +@OptIn(ExperimentalPermissionsApi::class) +private fun EntryProviderScope.registrationEntries( + registrationRepository: RegistrationRepository, + registrationViewModel: RegistrationViewModel, + permissionsState: MultiplePermissionsState, + navigator: RegistrationNavigator, + onRegistrationComplete: () -> Unit +) { + // --- Welcome Screen + entry { + WelcomeScreen( + onEvent = { event -> + when (event) { + WelcomeScreenEvents.Continue -> navigator.navigate(RegistrationRoute.Permissions(forRestore = false)) + WelcomeScreenEvents.DoesNotHaveOldPhone -> navigator.navigate(RegistrationRoute.Restore) + WelcomeScreenEvents.HasOldPhone -> navigator.navigate(RegistrationRoute.Permissions(forRestore = true)) + } + } + ) + } + + // --- Permissions Screen + entry { key -> + PermissionsScreen( + permissionsState = permissionsState, + onProceed = { + if (key.forRestore) { + navigator.navigate(RegistrationRoute.RestoreViaQr) + } else { + navigator.navigate(RegistrationRoute.PhoneNumberEntry) + } + } + ) + } + + // -- Phone Number Entry Screen + entry { + val viewModel: PhoneNumberEntryViewModel = viewModel( + factory = PhoneNumberEntryViewModel.Factory( + repository = registrationRepository, + parentState = registrationViewModel.state, + parentEventEmitter = registrationViewModel::onEvent + ) + ) + val state by viewModel.state.collectAsStateWithLifecycle() + + ResultEffect(registrationViewModel.resultBus, CAPTCHA_RESULT) { captchaToken -> + if (captchaToken != null) { + viewModel.onEvent(PhoneNumberEntryScreenEvents.CaptchaCompleted(captchaToken)) + } + } + + PhoneNumberScreen( + state = state, + onEvent = { viewModel.onEvent(it) } + ) + } + + // -- Country Code Picker + entry { + // We'll also want this to be some sort of launch-for-result flow as well + TODO() + } + + // -- Captcha Screen + entry { + CaptchaScreen( + state = CaptchaState( + captchaUrl = registrationRepository.getCaptchaUrl() + ), + onEvent = { event -> + when (event) { + is CaptchaScreenEvents.CaptchaCompleted -> { + registrationViewModel.resultBus.sendResult(CAPTCHA_RESULT, event.token) + navigator.goBack() + } + CaptchaScreenEvents.Cancel -> { + navigator.goBack() + } + } + } + ) + } + + // -- Verification Code Entry Screen + entry { + val viewModel: VerificationCodeViewModel = viewModel( + factory = VerificationCodeViewModel.Factory( + repository = registrationRepository, + parentState = registrationViewModel.state, + parentEventEmitter = registrationViewModel::onEvent + ) + ) + val state by viewModel.state.collectAsStateWithLifecycle() + + VerificationCodeScreen( + state = state, + onEvent = { viewModel.onEvent(it) } + ) + } + + entry { + // TODO: Implement ProfileScreen + } + + entry { + PinCreationScreen( + state = PinCreationState( + inputLabel = "PIN must be at least 4 digits" + ), + onEvent = { event -> + when (event) { + is PinCreationScreenEvents.PinSubmitted -> { + // TODO: Save PIN and navigate to next screen + onRegistrationComplete() + } + PinCreationScreenEvents.ToggleKeyboard -> { + // TODO: Toggle between numeric and alphanumeric keyboard + } + PinCreationScreenEvents.LearnMore -> { + // TODO: Show learn more dialog + } + } + } + ) + } + + entry { + // TODO: Implement RestoreScreen + } + + entry { + RestoreViaQrScreen( + state = RestoreViaQrState(), + onEvent = { event -> + when (event) { + RestoreViaQrScreenEvents.RetryQrCode -> { + // TODO: Retry QR code generation + } + RestoreViaQrScreenEvents.Cancel -> { + navigator.goBack() + } + RestoreViaQrScreenEvents.UseProxy -> { + // TODO: Navigate to proxy settings + } + RestoreViaQrScreenEvents.DismissError -> { + // TODO: Clear error state + } + } + } + ) + } + + entry { + // TODO: Implement TransferScreen + } + + entry { + LaunchedEffect(Unit) { + onRegistrationComplete() + } + } +} + +/** + * Navigator for the registration flow. + * Handles navigation events by updating the back stack. + */ +private class RegistrationNavigator( + private val eventEmitter: (RegistrationFlowEvent) -> Unit +) { + + fun navigate(route: RegistrationRoute) { + eventEmitter(RegistrationFlowEvent.NavigateToScreen(route)) + } + + fun goBack() { + eventEmitter(RegistrationFlowEvent.NavigateBack) + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/RegistrationRepository.kt b/registration/lib/src/main/java/org/signal/registration/RegistrationRepository.kt new file mode 100644 index 0000000000..e598eb6746 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -0,0 +1,136 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.signal.registration.NetworkController.AccountAttributes +import org.signal.registration.NetworkController.CreateSessionError +import org.signal.registration.NetworkController.PreKeyCollection +import org.signal.registration.NetworkController.RegisterAccountError +import org.signal.registration.NetworkController.RegisterAccountResponse +import org.signal.registration.NetworkController.RegistrationNetworkResult +import org.signal.registration.NetworkController.RequestVerificationCodeError +import org.signal.registration.NetworkController.SessionMetadata +import org.signal.registration.NetworkController.UpdateSessionError +import java.util.Locale + +class RegistrationRepository(val networkController: NetworkController, val storageController: StorageController) { + + suspend fun createSession(e164: String): RegistrationNetworkResult = withContext(Dispatchers.IO) { + val fcmToken = networkController.getFcmToken() + networkController.createSession( + e164 = e164, + fcmToken = fcmToken, + mcc = null, + mnc = null + ) + } + + suspend fun requestVerificationCode( + sessionId: String, + smsAutoRetrieveCodeSupported: Boolean, + transport: NetworkController.VerificationCodeTransport + ): RegistrationNetworkResult = withContext(Dispatchers.IO) { + networkController.requestVerificationCode( + sessionId = sessionId, + locale = Locale.getDefault(), + androidSmsRetrieverSupported = smsAutoRetrieveCodeSupported, + transport = transport + ) + } + + fun getCaptchaUrl(): String = networkController.getCaptchaUrl() + + suspend fun submitCaptchaToken( + sessionId: String, + captchaToken: String + ): RegistrationNetworkResult = withContext(Dispatchers.IO) { + networkController.updateSession( + sessionId = sessionId, + pushChallengeToken = null, + captchaToken = captchaToken + ) + } + + suspend fun submitVerificationCode( + sessionId: String, + verificationCode: String + ): RegistrationNetworkResult = withContext(Dispatchers.IO) { + networkController.submitVerificationCode( + sessionId = sessionId, + verificationCode = verificationCode + ) + } + + /** + * Registers a new account after successful phone number verification. + * + * This method: + * 1. Generates and stores all required cryptographic key material + * 2. Creates account attributes with registration IDs and capabilities + * 3. Calls the network controller to register the account + * + * @param e164 The phone number in E.164 format (used for basic auth) + * @param sessionId The verified session ID from phone number verification + * @param skipDeviceTransfer Whether to skip device transfer flow + * @return The registration result containing account information or an error + */ + suspend fun registerAccount( + e164: String, + sessionId: String, + skipDeviceTransfer: Boolean = true + ): RegistrationNetworkResult = withContext(Dispatchers.IO) { + val keyMaterial = storageController.generateAndStoreKeyMaterial() + val fcmToken = networkController.getFcmToken() + + // TODO this will need to be re-usable for reglocked accounts too (i.e. can't assume no reglock) + val accountAttributes = AccountAttributes( + signalingKey = null, + registrationId = keyMaterial.aciRegistrationId, + voice = true, + video = true, + fetchesMessages = fcmToken == null, + registrationLock = null, + unidentifiedAccessKey = keyMaterial.unidentifiedAccessKey, + unrestrictedUnidentifiedAccess = false, + discoverableByPhoneNumber = false, // Important -- this should be false initially, and then the user should be given a choice as to whether to turn it on later + capabilities = AccountAttributes.Capabilities( // TODO probably want to have this come from the app + storage = false, + versionedExpirationTimer = true, + attachmentBackfill = true, + spqr = true + ), + name = null, + pniRegistrationId = keyMaterial.pniRegistrationId, + recoveryPassword = null + ) + + val aciPreKeys = PreKeyCollection( + identityKey = keyMaterial.aciIdentityKeyPair.publicKey, + signedPreKey = keyMaterial.aciSignedPreKey, + lastResortKyberPreKey = keyMaterial.aciLastResortKyberPreKey + ) + + val pniPreKeys = PreKeyCollection( + identityKey = keyMaterial.pniIdentityKeyPair.publicKey, + signedPreKey = keyMaterial.pniSignedPreKey, + lastResortKyberPreKey = keyMaterial.pniLastResortKyberPreKey + ) + + networkController.registerAccount( + e164 = e164, + password = keyMaterial.servicePassword, + sessionId = sessionId, + recoveryPassword = null, + attributes = accountAttributes, + aciPreKeys = aciPreKeys, + pniPreKeys = pniPreKeys, + fcmToken = fcmToken, + skipDeviceTransfer = skipDeviceTransfer + ) + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/RegistrationViewModel.kt b/registration/lib/src/main/java/org/signal/registration/RegistrationViewModel.kt new file mode 100644 index 0000000000..75458802c8 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/RegistrationViewModel.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration + +import android.Manifest +import android.os.Build +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.CreationExtras +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.signal.core.ui.navigation.ResultEventBus +import org.signal.core.util.logging.Log +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) : ViewModel() { + + companion object { + private val TAG = Log.tag(RegistrationViewModel::class) + } + + private var _state: MutableStateFlow = savedStateHandle.getMutableStateFlow("registration_state", initialValue = RegistrationFlowState()) + val state: StateFlow = _state.asStateFlow() + + val resultBus = ResultEventBus() + + fun onEvent(event: RegistrationFlowEvent) { + _state.value = applyEvent(_state.value, event) + } + + fun applyEvent(state: RegistrationFlowState, event: RegistrationFlowEvent): RegistrationFlowState { + return when (event) { + is RegistrationFlowEvent.ResetState -> RegistrationFlowState() + is RegistrationFlowEvent.SessionUpdated -> state.copy(sessionMetadata = event.session) + is RegistrationFlowEvent.E164Chosen -> state.copy(sessionE164 = event.e164) + is RegistrationFlowEvent.NavigateToScreen -> applyNavigationToScreenEvent(state, event) + is RegistrationFlowEvent.NavigateBack -> state.copy(backStack = state.backStack.dropLast(1)) + } + } + + private fun applyNavigationToScreenEvent(inputState: RegistrationFlowState, event: RegistrationFlowEvent.NavigateToScreen): RegistrationFlowState { + val state = inputState.copy(backStack = inputState.backStack + event.route) + + return when (event.route) { + is RegistrationRoute.VerificationCodeEntry -> { + state.copy(sessionMetadata = event.route.session, sessionE164 = event.route.e164) + } + else -> state + } + } + + /** + * Returns the list of permissions to request based on the current API level. + */ + fun getRequiredPermissions(): List { + return buildList { + // Notifications (API 33+) + if (Build.VERSION.SDK_INT >= 33) { + add(Manifest.permission.POST_NOTIFICATIONS) + } + + // Contacts + add(Manifest.permission.READ_CONTACTS) + add(Manifest.permission.WRITE_CONTACTS) + + // Storage/Media + if (Build.VERSION.SDK_INT < 29) { + add(Manifest.permission.READ_EXTERNAL_STORAGE) + add(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + + // Phone state + add(Manifest.permission.READ_PHONE_STATE) + if (Build.VERSION.SDK_INT >= 26) { + add(Manifest.permission.READ_PHONE_NUMBERS) + } + } + } + + class Factory(private val repository: RegistrationRepository) : ViewModelProvider.Factory { + override fun create(modelClass: KClass, extras: CreationExtras): T { + return RegistrationViewModel(repository, extras.createSavedStateHandle()) as T + } + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/StorageController.kt b/registration/lib/src/main/java/org/signal/registration/StorageController.kt new file mode 100644 index 0000000000..1d9a2a82af --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/StorageController.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration + +import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.protocol.state.KyberPreKeyRecord +import org.signal.libsignal.protocol.state.SignedPreKeyRecord + +interface StorageController { + + /** + * Generates all key material required for account registration and stores it persistently. + * This includes ACI identity key, PNI identity key, and their respective pre-keys. + * + * @return [KeyMaterial] containing all generated cryptographic material needed for registration. + */ + suspend fun generateAndStoreKeyMaterial(): KeyMaterial +} + +/** + * Container for all cryptographic key material generated during registration. + */ +data class KeyMaterial( + /** Identity key pair for the Account Identity (ACI). */ + val aciIdentityKeyPair: IdentityKeyPair, + /** Signed pre-key for ACI. */ + val aciSignedPreKey: SignedPreKeyRecord, + /** Last resort Kyber pre-key for ACI. */ + val aciLastResortKyberPreKey: KyberPreKeyRecord, + /** Identity key pair for the Phone Number Identity (PNI). */ + val pniIdentityKeyPair: IdentityKeyPair, + /** Signed pre-key for PNI. */ + val pniSignedPreKey: SignedPreKeyRecord, + /** Last resort Kyber pre-key for PNI. */ + val pniLastResortKyberPreKey: KyberPreKeyRecord, + /** Registration ID for the ACI. */ + val aciRegistrationId: Int, + /** Registration ID for the PNI. */ + val pniRegistrationId: Int, + /** Unidentified access key (derived from profile key) for sealed sender. */ + val unidentifiedAccessKey: ByteArray, + /** Password for basic auth during registration (18 random bytes, base64 encoded). */ + val servicePassword: String +) diff --git a/registration/lib/src/main/java/org/signal/registration/screens/RegistrationHostScreen.kt b/registration/lib/src/main/java/org/signal/registration/screens/RegistrationHostScreen.kt new file mode 100644 index 0000000000..e30748cbd7 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/RegistrationHostScreen.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.MultiplePermissionsState +import org.signal.registration.RegistrationNavHost +import org.signal.registration.RegistrationRepository +import org.signal.registration.RegistrationViewModel + +/** + * Entry point for the registration flow. + * + * This composable sets up the entire registration navigation flow and can be + * embedded into the main app's navigation or launched as a standalone flow. + * + * @param viewModel The shared ViewModel for the registration flow. + * @param permissionsState The permissions state managed at the activity level. + * @param modifier Modifier to be applied to the root container. + * @param onRegistrationComplete Callback invoked when the registration process is successfully completed. + */ +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun RegistrationHostScreen( + registrationRepository: RegistrationRepository, + viewModel: RegistrationViewModel, + permissionsState: MultiplePermissionsState, + modifier: Modifier = Modifier, + onRegistrationComplete: () -> Unit = {} +) { + RegistrationNavHost( + registrationRepository = registrationRepository, + registrationViewModel = viewModel, + permissionsState = permissionsState, + modifier = modifier.fillMaxSize(), + onRegistrationComplete = onRegistrationComplete + ) +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/captcha/CaptchaScreen.kt b/registration/lib/src/main/java/org/signal/registration/screens/captcha/CaptchaScreen.kt new file mode 100644 index 0000000000..268132a4d1 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/captcha/CaptchaScreen.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.captcha + +import android.annotation.SuppressLint +import android.view.ViewGroup +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews + +/** + * Screen to display a captcha verification using a WebView. + * The WebView loads the Signal captcha URL and intercepts the callback + * when the user completes the captcha. + */ +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun CaptchaScreen( + state: CaptchaState, + onEvent: (CaptchaScreenEvents) -> Unit, + modifier: Modifier = Modifier +) { + var loadState by remember { mutableStateOf(state.loadState) } + + Column( + modifier = modifier + .fillMaxSize() + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + AndroidView( + factory = { context -> + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + settings.javaScriptEnabled = true + clearCache(true) + + webViewClient = object : WebViewClient() { + @Deprecated("Deprecated in Java") + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { + if (url.startsWith(state.captchaScheme)) { + val token = url.substring(state.captchaScheme.length) + onEvent(CaptchaScreenEvents.CaptchaCompleted(token)) + return true + } + return false + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + loadState = CaptchaLoadState.Loaded + } + + override fun onReceivedError( + view: WebView?, + errorCode: Int, + description: String?, + failingUrl: String? + ) { + super.onReceivedError(view, errorCode, description, failingUrl) + loadState = CaptchaLoadState.Error + } + } + + loadUrl(state.captchaUrl) + } + }, + modifier = Modifier.fillMaxSize() + ) + + when (loadState) { + CaptchaLoadState.Loaded -> Unit + CaptchaLoadState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + } + } + CaptchaLoadState.Error -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Failed to load captcha", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error + ) + } + } + } + } + + TextButton( + onClick = { onEvent(CaptchaScreenEvents.Cancel) }, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(16.dp) + ) { + Text("Cancel") + } + } +} + +@DayNightPreviews +@Composable +private fun CaptchaScreenLoadingPreview() { + Previews.Preview { + CaptchaScreen( + state = CaptchaState( + captchaUrl = "https://example.com/captcha", + loadState = CaptchaLoadState.Loading + ), + onEvent = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun CaptchaScreenErrorPreview() { + Previews.Preview { + CaptchaScreen( + state = CaptchaState( + captchaUrl = "https://example.com/captcha", + loadState = CaptchaLoadState.Error + ), + onEvent = {} + ) + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/captcha/CaptchaScreenEvents.kt b/registration/lib/src/main/java/org/signal/registration/screens/captcha/CaptchaScreenEvents.kt new file mode 100644 index 0000000000..407f27486f --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/captcha/CaptchaScreenEvents.kt @@ -0,0 +1,11 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.captcha + +sealed class CaptchaScreenEvents { + data class CaptchaCompleted(val token: String) : CaptchaScreenEvents() + data object Cancel : CaptchaScreenEvents() +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/captcha/CaptchaState.kt b/registration/lib/src/main/java/org/signal/registration/screens/captcha/CaptchaState.kt new file mode 100644 index 0000000000..690c107279 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/captcha/CaptchaState.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.captcha + +sealed class CaptchaLoadState { + data object Loading : CaptchaLoadState() + data object Loaded : CaptchaLoadState() + data object Error : CaptchaLoadState() +} + +data class CaptchaState( + val captchaUrl: String, + val captchaScheme: String = "signalcaptcha://", + val loadState: CaptchaLoadState = CaptchaLoadState.Loading +) diff --git a/registration/lib/src/main/java/org/signal/registration/screens/permissions/PermissionsScreen.kt b/registration/lib/src/main/java/org/signal/registration/screens/permissions/PermissionsScreen.kt new file mode 100644 index 0000000000..4d85fe3c6c --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/permissions/PermissionsScreen.kt @@ -0,0 +1,180 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +@file:OptIn(ExperimentalPermissionsApi::class) + +package org.signal.registration.screens.permissions + +import android.Manifest +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.MultiplePermissionsState +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.registration.screens.util.MockMultiplePermissionsState +import org.signal.registration.screens.util.MockPermissionsState +import org.signal.registration.test.TestTags + +/** + * Permissions screen for the registration flow. + * Requests necessary runtime permissions before continuing. + * + * @param permissionsState The permissions state managed at the activity level. + * @param onEvent Callback for screen events. + * @param modifier Modifier to be applied to the root container. + */ +@Composable +fun PermissionsScreen( + permissionsState: MultiplePermissionsState, + onProceed: () -> Unit = {}, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Permissions", + style = MaterialTheme.typography.headlineLarge, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Signal needs the following permissions to provide the best experience:", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(24.dp)) + + PermissionsList(permissions = permissionsState.permissions.map { it.permission }) + + Spacer(modifier = Modifier.height(48.dp)) + + Button( + onClick = { + permissionsState.launchMultiplePermissionRequest() + onProceed() + }, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.PERMISSIONS_NEXT_BUTTON) + ) { + Text("Next") + } + + OutlinedButton( + onClick = { onProceed() }, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.PERMISSIONS_NOT_NOW_BUTTON) + ) { + Text("Not now") + } + } +} + +/** + * Displays a list of permission explanations. + */ +@Composable +private fun PermissionsList( + permissions: List, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + val permissionDescriptions = getPermissionDescriptions(permissions) + permissionDescriptions.forEach { description -> + PermissionItem(description = description) + } + } +} + +/** + * Individual permission item with description. + */ +@Composable +private fun PermissionItem( + description: String, + modifier: Modifier = Modifier +) { + Text( + text = "• $description", + style = MaterialTheme.typography.bodyMedium, + modifier = modifier.fillMaxWidth() + ) +} + +/** + * Converts permission names to user-friendly descriptions. + */ +private fun getPermissionDescriptions(permissions: List): List { + return buildList { + if (permissions.any { it == Manifest.permission.POST_NOTIFICATIONS }) { + add("Notifications - Stay updated with new messages") + } + if (permissions.any { it == Manifest.permission.READ_CONTACTS || it == Manifest.permission.WRITE_CONTACTS }) { + add("Contacts - Find friends who use Signal") + } + if (permissions.any { + it == Manifest.permission.READ_EXTERNAL_STORAGE || + it == Manifest.permission.WRITE_EXTERNAL_STORAGE || + it == Manifest.permission.READ_MEDIA_IMAGES || + it == Manifest.permission.READ_MEDIA_VIDEO || + it == Manifest.permission.READ_MEDIA_AUDIO + } + ) { + add("Photos and media - Share images and videos") + } + if (permissions.any { it == Manifest.permission.READ_PHONE_STATE || it == Manifest.permission.READ_PHONE_NUMBERS }) { + add("Phone - Verify your phone number") + } + } +} + +@DayNightPreviews +@Composable +private fun PermissionsScreenPreview() { + Previews.Preview { + PermissionsScreen( + permissionsState = MockMultiplePermissionsState( + permissions = listOf( + Manifest.permission.POST_NOTIFICATIONS, + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS, + Manifest.permission.READ_PHONE_STATE, + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ).map { MockPermissionsState(it) } + ), + onProceed = {} + ) + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt b/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt new file mode 100644 index 0000000000..7f1603a0d2 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt @@ -0,0 +1,243 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.phonenumber + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.registration.screens.phonenumber.PhoneNumberEntryState.OneTimeEvent +import org.signal.registration.test.TestTags + +/** + * Phone number entry screen for the registration flow. + * Allows users to select their country and enter their phone number. + */ +@Composable +fun PhoneNumberScreen( + state: PhoneNumberEntryState, + onEvent: (PhoneNumberEntryScreenEvents) -> Unit, + modifier: Modifier = Modifier +) { + // TODO: These should come from state once country picker is implemented + var selectedCountry by remember { mutableStateOf("United States") } + var selectedCountryEmoji by remember { mutableStateOf("🇺🇸") } + + // Track the phone number text field value with cursor position + var phoneNumberTextFieldValue by remember { mutableStateOf(TextFieldValue(state.formattedNumber)) } + + // Update the text field value when state.formattedNumber changes, preserving cursor position + LaunchedEffect(state.formattedNumber) { + if (phoneNumberTextFieldValue.text != state.formattedNumber) { + // Calculate cursor position: count digits before cursor in old text, + // then find position with same digit count in new text + val oldText = phoneNumberTextFieldValue.text + val oldCursorPos = phoneNumberTextFieldValue.selection.end + val digitsBeforeCursor = oldText.take(oldCursorPos).count { it.isDigit() } + + val newText = state.formattedNumber + var digitCount = 0 + var newCursorPos = newText.length + for (i in newText.indices) { + if (newText[i].isDigit()) { + digitCount++ + } + if (digitCount >= digitsBeforeCursor) { + newCursorPos = i + 1 + break + } + } + + phoneNumberTextFieldValue = TextFieldValue( + text = newText, + selection = TextRange(newCursorPos) + ) + } + } + + LaunchedEffect(state.oneTimeEvent) { + onEvent(PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent) + when (state.oneTimeEvent) { + OneTimeEvent.NetworkError -> TODO() + is OneTimeEvent.RateLimited -> TODO() + OneTimeEvent.UnknownError -> TODO() + OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> TODO() + OneTimeEvent.ThirdPartyError -> TODO() + null -> Unit + } + } + + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.Start + ) { + // Title + Text( + text = "Phone number", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Subtitle + Text( + text = "You will receive a verification code", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(36.dp)) + + // Country Picker Button + OutlinedButton( + onClick = { + onEvent(PhoneNumberEntryScreenEvents.CountryPicker) + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .testTag(TestTags.PHONE_NUMBER_COUNTRY_PICKER) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = selectedCountryEmoji, + style = MaterialTheme.typography.headlineSmall + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = selectedCountry, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Icon( + painter = painterResource(android.R.drawable.arrow_down_float), + contentDescription = "Select country", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + // Phone number input fields + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(20.dp) + ) { + // Country code field + OutlinedTextField( + value = state.countryCode, + onValueChange = { onEvent(PhoneNumberEntryScreenEvents.CountryCodeChanged(it)) }, + modifier = Modifier + .width(76.dp) + .testTag(TestTags.PHONE_NUMBER_COUNTRY_CODE_FIELD), + leadingIcon = { + Text( + text = "+", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next + ), + singleLine = true + ) + + // Phone number field + OutlinedTextField( + value = phoneNumberTextFieldValue, + onValueChange = { newValue -> + phoneNumberTextFieldValue = newValue + onEvent(PhoneNumberEntryScreenEvents.PhoneNumberChanged(newValue.text)) + }, + modifier = Modifier + .weight(1f) + .testTag(TestTags.PHONE_NUMBER_PHONE_FIELD), + placeholder = { + Text("Phone number") + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + onEvent(PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) + } + ), + singleLine = true + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + // Next button + Button( + onClick = { + onEvent(PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .testTag(TestTags.PHONE_NUMBER_NEXT_BUTTON), + enabled = state.countryCode.isNotEmpty() && state.nationalNumber.isNotEmpty() + ) { + Text("Next") + } + } +} + +@DayNightPreviews +@Composable +private fun PhoneNumberScreenPreview() { + Previews.Preview { + PhoneNumberScreen( + state = PhoneNumberEntryState(), + onEvent = {} + ) + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt b/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt new file mode 100644 index 0000000000..e548b492b5 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.phonenumber + +sealed interface PhoneNumberEntryScreenEvents { + data class CountryCodeChanged(val value: String) : PhoneNumberEntryScreenEvents + data class PhoneNumberChanged(val value: String) : PhoneNumberEntryScreenEvents + data object PhoneNumberSubmitted : PhoneNumberEntryScreenEvents + data object CountryPicker : PhoneNumberEntryScreenEvents + data class CaptchaCompleted(val token: String) : PhoneNumberEntryScreenEvents + data object ConsumeOneTimeEvent : PhoneNumberEntryScreenEvents +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt b/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt new file mode 100644 index 0000000000..e222878a02 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.phonenumber + +import org.signal.registration.NetworkController.SessionMetadata +import kotlin.time.Duration + +data class PhoneNumberEntryState( + val regionCode: String = "US", + val countryCode: String = "1", + val nationalNumber: String = "", + val formattedNumber: String = "", + val sessionMetadata: SessionMetadata? = null, + val oneTimeEvent: OneTimeEvent? = null +) { + sealed interface OneTimeEvent { + data object NetworkError : OneTimeEvent + data object UnknownError : OneTimeEvent + data class RateLimited(val retryAfter: Duration) : OneTimeEvent + data object ThirdPartyError : OneTimeEvent + data object CouldNotRequestCodeWithSelectedTransport : OneTimeEvent + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt b/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt new file mode 100644 index 0000000000..bf6ee703c0 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt @@ -0,0 +1,303 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.phonenumber + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.google.i18n.phonenumbers.AsYouTypeFormatter +import com.google.i18n.phonenumbers.PhoneNumberUtil +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.signal.core.util.logging.Log +import org.signal.registration.NetworkController +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.phonenumber.PhoneNumberEntryState.OneTimeEvent +import org.signal.registration.screens.util.navigateTo + +class PhoneNumberEntryViewModel( + val repository: RegistrationRepository, + private val parentState: StateFlow, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit +) : ViewModel() { + + companion object { + private val TAG = Log.tag(PhoneNumberEntryViewModel::class) + } + + private val phoneNumberUtil: PhoneNumberUtil = PhoneNumberUtil.getInstance() + private var formatter: AsYouTypeFormatter = phoneNumberUtil.getAsYouTypeFormatter("US") + + private val _state = MutableStateFlow(PhoneNumberEntryState()) + val state = combine(_state, parentState) { state, parentState -> applyParentState(state, parentState) } + .onEach { Log.d(TAG, "[State] $it") } + .stateIn(viewModelScope, SharingStarted.Eagerly, PhoneNumberEntryState()) + + fun onEvent(event: PhoneNumberEntryScreenEvents) { + viewModelScope.launch { + _state.emit(applyEvent(_state.value, event)) + } + } + + suspend fun applyEvent(state: PhoneNumberEntryState, event: PhoneNumberEntryScreenEvents): PhoneNumberEntryState { + return when (event) { + is PhoneNumberEntryScreenEvents.CountryCodeChanged -> transformCountryCodeChanged(state, event.value) + is PhoneNumberEntryScreenEvents.PhoneNumberChanged -> transformPhoneNumberChanged(state, event.value) + is PhoneNumberEntryScreenEvents.PhoneNumberSubmitted -> transformPhoneNumberSubmitted(state) + is PhoneNumberEntryScreenEvents.CountryPicker -> state.also { parentEventEmitter.navigateTo(RegistrationRoute.CountryCodePicker) } + is PhoneNumberEntryScreenEvents.CaptchaCompleted -> transformCaptchaCompleted(state, event.token) + is PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent -> state.copy(oneTimeEvent = null) + } + } + + fun applyParentState(state: PhoneNumberEntryState, parentState: RegistrationFlowState): PhoneNumberEntryState { + return state.copy(sessionMetadata = parentState.sessionMetadata) + } + + private fun transformCountryCodeChanged(state: PhoneNumberEntryState, countryCode: String): PhoneNumberEntryState { + // Only allow digits, max 3 characters + val sanitized = countryCode.filter { it.isDigit() }.take(3) + if (sanitized == state.countryCode) return state + + // Try to determine region from country code + val regionCode = phoneNumberUtil.getRegionCodeForCountryCode(sanitized.toIntOrNull() ?: 0) ?: state.regionCode + + // Reset formatter for new region and reformat the existing national number + formatter = phoneNumberUtil.getAsYouTypeFormatter(regionCode) + val formattedNumber = formatNumber(state.nationalNumber) + + return state.copy( + countryCode = sanitized, + regionCode = regionCode, + formattedNumber = formattedNumber + ) + } + + private fun transformPhoneNumberChanged(state: PhoneNumberEntryState, input: String): PhoneNumberEntryState { + // Extract only digits from the input + val digitsOnly = input.filter { it.isDigit() } + if (digitsOnly == state.nationalNumber) return state + + // Format the number using AsYouTypeFormatter + val formattedNumber = formatNumber(digitsOnly) + + return state.copy( + nationalNumber = digitsOnly, + formattedNumber = formattedNumber + ) + } + + private fun formatNumber(nationalNumber: String): String { + formatter.clear() + var result = "" + for (digit in nationalNumber) { + result = formatter.inputDigit(digit) + } + return result + } + + private suspend fun transformPhoneNumberSubmitted( + inputState: PhoneNumberEntryState + ): PhoneNumberEntryState { + val e164 = "+${inputState.countryCode}${inputState.nationalNumber}" + var state = inputState.copy() + + // TODO Consider that someone may back into this screen and change the number, requiring us to create a new session. + + var sessionMetadata: NetworkController.SessionMetadata = state.sessionMetadata ?: when (val response = this@PhoneNumberEntryViewModel.repository.createSession(e164)) { + is NetworkController.RegistrationNetworkResult.Success -> { + response.data + } + is NetworkController.RegistrationNetworkResult.Failure -> { + return when (response.error) { + is NetworkController.CreateSessionError.InvalidRequest -> { + state.copy(oneTimeEvent = OneTimeEvent.UnknownError) + } + is NetworkController.CreateSessionError.RateLimited -> { + state.copy(oneTimeEvent = OneTimeEvent.RateLimited(response.error.retryAfter)) + } + } + } + is NetworkController.RegistrationNetworkResult.NetworkError -> { + return state.copy(oneTimeEvent = OneTimeEvent.NetworkError) + } + is NetworkController.RegistrationNetworkResult.ApplicationError -> { + Log.w(TAG, "Unknown error when creating session.", response.exception) + return state.copy(oneTimeEvent = OneTimeEvent.UnknownError) + } + } + + state = state.copy(sessionMetadata = sessionMetadata) + + if (sessionMetadata.requestedInformation.contains("captcha")) { + parentEventEmitter.navigateTo(RegistrationRoute.Captcha(sessionMetadata)) + return state + } + + val verificationCodeResponse = this@PhoneNumberEntryViewModel.repository.requestVerificationCode( + sessionMetadata.id, + smsAutoRetrieveCodeSupported = false, + transport = NetworkController.VerificationCodeTransport.SMS + ) + + sessionMetadata = when (verificationCodeResponse) { + is NetworkController.RegistrationNetworkResult.Success -> { + verificationCodeResponse.data + } + is NetworkController.RegistrationNetworkResult.Failure -> { + return when (verificationCodeResponse.error) { + is NetworkController.RequestVerificationCodeError.InvalidRequest -> { + state.copy(oneTimeEvent = OneTimeEvent.UnknownError) + } + is NetworkController.RequestVerificationCodeError.RateLimited -> { + state.copy(oneTimeEvent = OneTimeEvent.RateLimited(verificationCodeResponse.error.retryAfter)) + } + is NetworkController.RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport -> { + state.copy(oneTimeEvent = OneTimeEvent.CouldNotRequestCodeWithSelectedTransport) + } + is NetworkController.RequestVerificationCodeError.InvalidSessionId -> { + parentEventEmitter(RegistrationFlowEvent.ResetState) + state + } + is NetworkController.RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified -> { + Log.w(TAG, "When requesting verification code, missing request information or already verified.") + state.copy(oneTimeEvent = OneTimeEvent.NetworkError) + } + is NetworkController.RequestVerificationCodeError.SessionNotFound -> { + parentEventEmitter(RegistrationFlowEvent.ResetState) + state + } + is NetworkController.RequestVerificationCodeError.ThirdPartyServiceError -> { + state.copy(oneTimeEvent = OneTimeEvent.ThirdPartyError) + } + } + } + is NetworkController.RegistrationNetworkResult.NetworkError -> { + return state.copy(oneTimeEvent = OneTimeEvent.NetworkError) + } + is NetworkController.RegistrationNetworkResult.ApplicationError -> { + Log.w(TAG, "Unknown error when creating session.", verificationCodeResponse.exception) + return state.copy(oneTimeEvent = OneTimeEvent.UnknownError) + } + } + + state = state.copy(sessionMetadata = sessionMetadata) + + if (sessionMetadata.requestedInformation.contains("captcha")) { + parentEventEmitter.navigateTo(RegistrationRoute.Captcha(sessionMetadata)) + return state + } + + parentEventEmitter.navigateTo(RegistrationRoute.VerificationCodeEntry(sessionMetadata, e164)) + return state + } + + private suspend fun transformCaptchaCompleted(inputState: PhoneNumberEntryState, token: String): PhoneNumberEntryState { + var state = inputState.copy() + var sessionMetadata = state.sessionMetadata ?: return state.copy(oneTimeEvent = OneTimeEvent.UnknownError) + + val updateResult = this@PhoneNumberEntryViewModel.repository.submitCaptchaToken(sessionMetadata.id, token) + + sessionMetadata = when (updateResult) { + is NetworkController.RegistrationNetworkResult.Success -> updateResult.data + is NetworkController.RegistrationNetworkResult.Failure -> { + return when (updateResult.error) { + is NetworkController.UpdateSessionError.InvalidRequest -> { + state.copy(oneTimeEvent = OneTimeEvent.UnknownError) + } + is NetworkController.UpdateSessionError.RejectedUpdate -> { + state.copy(oneTimeEvent = OneTimeEvent.UnknownError) + } + is NetworkController.UpdateSessionError.RateLimited -> { + state.copy(oneTimeEvent = OneTimeEvent.RateLimited(updateResult.error.retryAfter)) + } + } + } + is NetworkController.RegistrationNetworkResult.NetworkError -> { + return state.copy(oneTimeEvent = OneTimeEvent.NetworkError) + } + is NetworkController.RegistrationNetworkResult.ApplicationError -> { + Log.w(TAG, "Unknown error when submitting captcha.", updateResult.exception) + return state.copy(oneTimeEvent = OneTimeEvent.UnknownError) + } + } + + state = state.copy(sessionMetadata = sessionMetadata) + + // TODO should we be reading "allowedToRequestCode"? + if (sessionMetadata.requestedInformation.contains("captcha")) { + parentEventEmitter.navigateTo(RegistrationRoute.Captcha(sessionMetadata)) + return state + } + + val verificationCodeResponse = this@PhoneNumberEntryViewModel.repository.requestVerificationCode( + sessionId = sessionMetadata.id, + smsAutoRetrieveCodeSupported = false, // TODO eventually support this + transport = NetworkController.VerificationCodeTransport.SMS + ) + + sessionMetadata = when (verificationCodeResponse) { + is NetworkController.RegistrationNetworkResult.Success -> verificationCodeResponse.data + is NetworkController.RegistrationNetworkResult.Failure -> { + return when (verificationCodeResponse.error) { + is NetworkController.RequestVerificationCodeError.InvalidRequest -> { + state.copy(oneTimeEvent = OneTimeEvent.UnknownError) + } + is NetworkController.RequestVerificationCodeError.RateLimited -> { + state.copy(oneTimeEvent = OneTimeEvent.RateLimited(verificationCodeResponse.error.retryAfter)) + } + is NetworkController.RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport -> { + state.copy(oneTimeEvent = OneTimeEvent.CouldNotRequestCodeWithSelectedTransport) + } + is NetworkController.RequestVerificationCodeError.InvalidSessionId -> { + parentEventEmitter(RegistrationFlowEvent.ResetState) + state + } + is NetworkController.RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified -> { + TODO() + } + is NetworkController.RequestVerificationCodeError.SessionNotFound -> { + parentEventEmitter(RegistrationFlowEvent.ResetState) + state + } + is NetworkController.RequestVerificationCodeError.ThirdPartyServiceError -> { + state.copy(oneTimeEvent = OneTimeEvent.ThirdPartyError) + } + } + } + is NetworkController.RegistrationNetworkResult.NetworkError -> { + return state.copy(oneTimeEvent = OneTimeEvent.NetworkError) + } + is NetworkController.RegistrationNetworkResult.ApplicationError -> { + Log.w(TAG, "Unknown error when requesting verification code.", verificationCodeResponse.exception) + return state.copy(oneTimeEvent = OneTimeEvent.UnknownError) + } + } + + val e164 = "+${inputState.countryCode}${inputState.nationalNumber}" + + parentEventEmitter.navigateTo(RegistrationRoute.VerificationCodeEntry(sessionMetadata, e164)) + return state + } + + class Factory( + val repository: RegistrationRepository, + val parentState: StateFlow, + val parentEventEmitter: (RegistrationFlowEvent) -> Unit + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return PhoneNumberEntryViewModel(repository, parentState, parentEventEmitter) as T + } + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt b/registration/lib/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt new file mode 100644 index 0000000000..d92415e1b1 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreen.kt @@ -0,0 +1,208 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.pincreation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.SignalIcons + +/** + * PIN creation screen for the registration flow. + * Allows users to create a new PIN for their account. + */ +@Composable +fun PinCreationScreen( + state: PinCreationState, + onEvent: (PinCreationScreenEvents) -> Unit, + modifier: Modifier = Modifier +) { + var pin by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + val scrollState = rememberScrollState() + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = "Create your PIN", + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + val descriptionText = buildAnnotatedString { + append("PINs can help you restore your account if you lose your phone. ") + pushStringAnnotation(tag = "LEARN_MORE", annotation = "learn_more") + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline + ) + ) { + append("Learn more") + } + pop() + } + + ClickableText( + text = descriptionText, + style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Start + ), + modifier = Modifier.fillMaxWidth(), + onClick = { offset -> + descriptionText.getStringAnnotations(tag = "LEARN_MORE", start = offset, end = offset) + .firstOrNull()?.let { + onEvent(PinCreationScreenEvents.LearnMore) + } + } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + TextField( + value = pin, + onValueChange = { pin = it }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + textStyle = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = if (state.isNumericKeyboard) KeyboardType.NumberPassword else KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + if (pin.length >= 4) { + onEvent(PinCreationScreenEvents.PinSubmitted(pin)) + } + } + ) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = state.inputLabel ?: "", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedButton( + onClick = { onEvent(PinCreationScreenEvents.ToggleKeyboard) }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + painter = SignalIcons.Keyboard.painter, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text( + text = if (state.isNumericKeyboard) "Create alphanumeric PIN" else "Create numeric PIN" + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + Button( + onClick = { onEvent(PinCreationScreenEvents.PinSubmitted(pin)) }, + enabled = pin.length >= 4 + ) { + Text("Next") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + + // Auto-focus PIN field on initial composition + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@DayNightPreviews +@Composable +private fun PinCreationScreenPreview() { + Previews.Preview { + PinCreationScreen( + state = PinCreationState( + inputLabel = "PIN must be at least 4 digits" + ), + onEvent = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun PinCreationScreenAlphanumericPreview() { + Previews.Preview { + PinCreationScreen( + state = PinCreationState( + isNumericKeyboard = false, + inputLabel = "PIN must be at least 4 characters" + ), + onEvent = {} + ) + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenEvents.kt b/registration/lib/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenEvents.kt new file mode 100644 index 0000000000..563df9f183 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenEvents.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.pincreation + +sealed class PinCreationScreenEvents { + data class PinSubmitted(val pin: String) : PinCreationScreenEvents() + data object ToggleKeyboard : PinCreationScreenEvents() + data object LearnMore : PinCreationScreenEvents() +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt b/registration/lib/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt new file mode 100644 index 0000000000..fd577909ca --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.pincreation + +data class PinCreationState( + val isNumericKeyboard: Boolean = true, + val inputLabel: String? = null, + val isConfirmEnabled: Boolean = false +) diff --git a/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt b/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt new file mode 100644 index 0000000000..f07b4a4e8f --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreen.kt @@ -0,0 +1,214 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.pinentry + +import androidx.compose.foundation.layout.Arrangement +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.SignalIcons + +/** + * PIN entry screen for the registration flow. + * Allows users to enter their PIN to restore their account. + */ +@Composable +fun PinEntryScreen( + state: PinEntryState, + onEvent: (PinEntryScreenEvents) -> Unit, + modifier: Modifier = Modifier +) { + var pin by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + val scrollState = rememberScrollState() + + Box( + modifier = modifier.fillMaxSize() + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = "Enter your PIN", + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "Enter the PIN you created when you first installed Signal", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + TextField( + value = pin, + onValueChange = { pin = it }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + textStyle = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = if (state.isNumericKeyboard) KeyboardType.Number else KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + if (pin.isNotEmpty()) { + onEvent(PinEntryScreenEvents.PinEntered(pin)) + } + } + ), + isError = state.errorMessage != null + ) + + if (state.errorMessage != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = state.errorMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } else { + Spacer(modifier = Modifier.height(8.dp)) + } + + if (state.showNeedHelp) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = { onEvent(PinEntryScreenEvents.NeedHelp) }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Need help?") + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + onClick = { onEvent(PinEntryScreenEvents.ToggleKeyboard) }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + painter = SignalIcons.Keyboard.painter, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text("Switch keyboard") + } + + Spacer(modifier = Modifier.weight(1f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + Button( + onClick = { + if (pin.isNotEmpty()) { + onEvent(PinEntryScreenEvents.PinEntered(pin)) + } + }, + enabled = pin.isNotEmpty() + ) { + Text("Continue") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + + // Skip button in top right + TextButton( + onClick = { onEvent(PinEntryScreenEvents.Skip) }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + ) { + Text( + text = "Skip", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Auto-focus PIN field on initial composition + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@DayNightPreviews +@Composable +private fun PinEntryScreenPreview() { + Previews.Preview { + PinEntryScreen( + state = PinEntryState(), + onEvent = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun PinEntryScreenWithErrorPreview() { + Previews.Preview { + PinEntryScreen( + state = PinEntryState( + errorMessage = "Incorrect PIN. Try again.", + showNeedHelp = true + ), + onEvent = {} + ) + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEvents.kt b/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEvents.kt new file mode 100644 index 0000000000..b95032474b --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryScreenEvents.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.pinentry + +sealed class PinEntryScreenEvents { + data class PinEntered(val pin: String) : PinEntryScreenEvents() + data object ToggleKeyboard : PinEntryScreenEvents() + data object NeedHelp : PinEntryScreenEvents() + data object Skip : PinEntryScreenEvents() +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryState.kt b/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryState.kt new file mode 100644 index 0000000000..05e0ec4bbe --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/pinentry/PinEntryState.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.pinentry + +data class PinEntryState( + val errorMessage: String? = null, + val showNeedHelp: Boolean = false, + val isNumericKeyboard: Boolean = true +) diff --git a/registration/lib/src/main/java/org/signal/registration/screens/restore/RestoreViaQrScreen.kt b/registration/lib/src/main/java/org/signal/registration/screens/restore/RestoreViaQrScreen.kt new file mode 100644 index 0000000000..b40c03a69c --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/restore/RestoreViaQrScreen.kt @@ -0,0 +1,296 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.restore + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.DayNightPreviews +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 + +/** + * Screen to display QR code for restoring from an old device. + * The old device scans this QR code to initiate the transfer. + */ +@Composable +fun RestoreViaQrScreen( + state: RestoreViaQrState, + onEvent: (RestoreViaQrScreenEvents) -> Unit, + modifier: Modifier = Modifier +) { + val scrollState = rememberScrollState() + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Scan from old device", + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(32.dp)) + + // QR Code display area + Box( + modifier = Modifier + .widthIn(max = 280.dp) + .aspectRatio(1f) + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + AnimatedContent( + targetState = state.qrState, + contentKey = { it::class }, + label = "qr-code-state" + ) { qrState -> + when (qrState) { + is QrState.Loaded -> { + QrCode( + data = qrState.qrCodeData, + foregroundColor = Color(0xFF2449C0), + modifier = Modifier.fillMaxSize() + ) + } + + QrState.Loading -> { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + } + + QrState.Scanned -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "QR code scanned", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button(onClick = { onEvent(RestoreViaQrScreenEvents.RetryQrCode) }) { + Text("Retry") + } + } + } + + QrState.Failed -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Failed to generate QR code", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button(onClick = { onEvent(RestoreViaQrScreenEvents.RetryQrCode) }) { + Text("Retry") + } + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Instructions + Column( + modifier = Modifier.widthIn(max = 320.dp) + ) { + InstructionRow( + icon = SignalIcons.Phone.painter, + instruction = "On your old phone, open Signal" + ) + + InstructionRow( + icon = SignalIcons.Camera.painter, + instruction = "Go to Settings > Transfer account" + ) + + InstructionRow( + icon = SignalIcons.QrCode.painter, + instruction = "Scan this QR code" + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + TextButton( + onClick = { onEvent(RestoreViaQrScreenEvents.Cancel) } + ) { + Text("Cancel") + } + + Spacer(modifier = Modifier.height(16.dp)) + } + + // Loading dialog + if (state.isRegistering) { + AlertDialog( + onDismissRequest = { }, + confirmButton = { }, + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(16.dp)) + Text("Registering...") + } + } + ) + } + + // Error dialog + if (state.showRegistrationError) { + AlertDialog( + onDismissRequest = { onEvent(RestoreViaQrScreenEvents.DismissError) }, + confirmButton = { + TextButton(onClick = { onEvent(RestoreViaQrScreenEvents.DismissError) }) { + Text("OK") + } + }, + text = { + Text(state.errorMessage ?: "An error occurred during registration") + } + ) + } +} + +@Composable +private fun InstructionRow( + icon: Painter, + instruction: String +) { + Row( + modifier = Modifier.padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = instruction, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@DayNightPreviews +@Composable +private fun RestoreViaQrScreenLoadingPreview() { + Previews.Preview { + RestoreViaQrScreen( + state = RestoreViaQrState(qrState = QrState.Loading), + onEvent = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun RestoreViaQrScreenLoadedPreview() { + Previews.Preview { + RestoreViaQrScreen( + state = RestoreViaQrState( + qrState = QrState.Loaded(QrCodeData.forData("sgnl://rereg?uuid=test&pub_key=test", false)) + ), + onEvent = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun RestoreViaQrScreenFailedPreview() { + Previews.Preview { + RestoreViaQrScreen( + state = RestoreViaQrState(qrState = QrState.Failed), + onEvent = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun RestoreViaQrScreenRegisteringPreview() { + Previews.Preview { + RestoreViaQrScreen( + state = RestoreViaQrState( + qrState = QrState.Scanned, + isRegistering = true + ), + onEvent = {} + ) + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/restore/RestoreViaQrScreenEvents.kt b/registration/lib/src/main/java/org/signal/registration/screens/restore/RestoreViaQrScreenEvents.kt new file mode 100644 index 0000000000..5f065590b7 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/restore/RestoreViaQrScreenEvents.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.restore + +sealed class RestoreViaQrScreenEvents { + data object RetryQrCode : RestoreViaQrScreenEvents() + data object Cancel : RestoreViaQrScreenEvents() + data object UseProxy : RestoreViaQrScreenEvents() + data object DismissError : RestoreViaQrScreenEvents() +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/restore/RestoreViaQrState.kt b/registration/lib/src/main/java/org/signal/registration/screens/restore/RestoreViaQrState.kt new file mode 100644 index 0000000000..7b483d6673 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/restore/RestoreViaQrState.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.restore + +import org.signal.core.ui.compose.QrCodeData + +sealed class QrState { + data object Loading : QrState() + data class Loaded(val qrCodeData: QrCodeData) : QrState() + data object Scanned : QrState() + data object Failed : QrState() +} + +data class RestoreViaQrState( + val qrState: QrState = QrState.Loading, + val isRegistering: Boolean = false, + val showRegistrationError: Boolean = false, + val errorMessage: String? = null +) diff --git a/registration/lib/src/main/java/org/signal/registration/screens/util/EmitterExtensions.kt b/registration/lib/src/main/java/org/signal/registration/screens/util/EmitterExtensions.kt new file mode 100644 index 0000000000..7982ac6633 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/util/EmitterExtensions.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.util + +import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationRoute + +fun ((RegistrationFlowEvent) -> Unit).navigateTo(route: RegistrationRoute) { + this(RegistrationFlowEvent.NavigateToScreen(route)) +} + +fun ((RegistrationFlowEvent) -> Unit).navigateBack() { + this(RegistrationFlowEvent.NavigateBack) +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/util/MockMultiplePermissionsState.kt b/registration/lib/src/main/java/org/signal/registration/screens/util/MockMultiplePermissionsState.kt new file mode 100644 index 0000000000..9c0c23177e --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/util/MockMultiplePermissionsState.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +@file:OptIn(ExperimentalPermissionsApi::class) + +package org.signal.registration.screens.util + +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.MultiplePermissionsState +import com.google.accompanist.permissions.PermissionState + +/** + * Helpful mock for [MultiplePermissionsState] to make previews easier. + */ +class MockMultiplePermissionsState( + override val allPermissionsGranted: Boolean = false, + override val permissions: List = emptyList(), + override val revokedPermissions: List = emptyList(), + override val shouldShowRationale: Boolean = false +) : MultiplePermissionsState { + override fun launchMultiplePermissionRequest() = Unit +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/util/MockPermissionsState.kt b/registration/lib/src/main/java/org/signal/registration/screens/util/MockPermissionsState.kt new file mode 100644 index 0000000000..f9278b010b --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/util/MockPermissionsState.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +@file:OptIn(ExperimentalPermissionsApi::class) + +package org.signal.registration.screens.util + +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.PermissionStatus + +/** + * Helpful mock for [PermissionsState] to make previews easier. + */ +class MockPermissionsState( + override val permission: String, + override val status: PermissionStatus = PermissionStatus.Granted +) : PermissionState { + override fun launchPermissionRequest() = Unit +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt b/registration/lib/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt new file mode 100644 index 0000000000..3cce17f1e9 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreen.kt @@ -0,0 +1,235 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.verificationcode + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.registration.test.TestTags + +/** + * Verification code entry screen for the registration flow. + * Displays a 6-digit code input in XXX-XXX format. + */ +@Composable +fun VerificationCodeScreen( + state: VerificationCodeState, + onEvent: (VerificationCodeScreenEvents) -> Unit, + modifier: Modifier = Modifier +) { + var digits by remember { mutableStateOf(List(6) { "" }) } + val focusRequesters = remember { List(6) { FocusRequester() } } + + // Auto-submit when all digits are entered + LaunchedEffect(digits) { + if (digits.all { it.isNotEmpty() }) { + val code = digits.joinToString("") + onEvent(VerificationCodeScreenEvents.CodeEntered(code)) + } + } + + LaunchedEffect(state.oneTimeEvent) { + onEvent(VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent) + + when (state.oneTimeEvent) { + VerificationCodeState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> { } + VerificationCodeState.OneTimeEvent.IncorrectVerificationCode -> { } + VerificationCodeState.OneTimeEvent.NetworkError -> { } + is VerificationCodeState.OneTimeEvent.RateLimited -> { } + VerificationCodeState.OneTimeEvent.ThirdPartyError -> { } + VerificationCodeState.OneTimeEvent.UnknownError -> { } + VerificationCodeState.OneTimeEvent.RegistrationError -> { } + null -> { } + } + } + + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + Spacer(modifier = Modifier.height(48.dp)) + + Text( + text = "Enter verification code", + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Enter the code we sent to ${state.e164}", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(32.dp)) + + // Code input fields - XXX-XXX format + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.VERIFICATION_CODE_INPUT), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + // First three digits + for (i in 0..2) { + DigitField( + value = digits[i], + onValueChange = { newValue -> + if (newValue.length <= 1 && (newValue.isEmpty() || newValue.all { it.isDigit() })) { + digits = digits.toMutableList().apply { this[i] = newValue } + if (newValue.isNotEmpty() && i < 5) { + focusRequesters[i + 1].requestFocus() + } + } + }, + focusRequester = focusRequesters[i], + testTag = when (i) { + 0 -> TestTags.VERIFICATION_CODE_DIGIT_0 + 1 -> TestTags.VERIFICATION_CODE_DIGIT_1 + else -> TestTags.VERIFICATION_CODE_DIGIT_2 + } + ) + if (i < 2) { + Spacer(modifier = Modifier.width(4.dp)) + } + } + + // Separator + Text( + text = "-", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(horizontal = 8.dp) + ) + + // Last three digits + for (i in 3..5) { + if (i > 3) { + Spacer(modifier = Modifier.width(4.dp)) + } + DigitField( + value = digits[i], + onValueChange = { newValue -> + if (newValue.length <= 1 && (newValue.isEmpty() || newValue.all { it.isDigit() })) { + digits = digits.toMutableList().apply { this[i] = newValue } + if (newValue.isNotEmpty() && i < 5) { + focusRequesters[i + 1].requestFocus() + } + } + }, + focusRequester = focusRequesters[i], + testTag = when (i) { + 3 -> TestTags.VERIFICATION_CODE_DIGIT_3 + 4 -> TestTags.VERIFICATION_CODE_DIGIT_4 + else -> TestTags.VERIFICATION_CODE_DIGIT_5 + } + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + TextButton( + onClick = { onEvent(VerificationCodeScreenEvents.WrongNumber) }, + modifier = Modifier.testTag(TestTags.VERIFICATION_CODE_WRONG_NUMBER_BUTTON) + ) { + Text("Wrong number?") + } + + Spacer(modifier = Modifier.weight(1f)) + + TextButton( + onClick = { onEvent(VerificationCodeScreenEvents.ResendSms) }, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.VERIFICATION_CODE_RESEND_SMS_BUTTON) + ) { + Text("Resend SMS") + } + + TextButton( + onClick = { onEvent(VerificationCodeScreenEvents.CallMe) }, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.VERIFICATION_CODE_CALL_ME_BUTTON) + ) { + Text("Call me instead") + } + } + + // Auto-focus first field on initial composition + LaunchedEffect(Unit) { + focusRequesters[0].requestFocus() + } +} + +/** + * Individual digit input field + */ +@Composable +private fun DigitField( + value: String, + onValueChange: (String) -> Unit, + focusRequester: FocusRequester, + testTag: String, + modifier: Modifier = Modifier +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier + .width(44.dp) + .focusRequester(focusRequester) + .testTag(testTag), + textStyle = MaterialTheme.typography.titleLarge.copy(textAlign = TextAlign.Center), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) +} + +@DayNightPreviews +@Composable +private fun VerificationCodeScreenPreview() { + Previews.Preview { + VerificationCodeScreen( + state = VerificationCodeState(), + onEvent = {} + ) + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt b/registration/lib/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt new file mode 100644 index 0000000000..f0e4c40759 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenEvents.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.verificationcode + +sealed class VerificationCodeScreenEvents { + data class CodeEntered(val code: String) : VerificationCodeScreenEvents() + data object WrongNumber : VerificationCodeScreenEvents() + data object ResendSms : VerificationCodeScreenEvents() + data object CallMe : VerificationCodeScreenEvents() + data object HavingTrouble : VerificationCodeScreenEvents() + data object ConsumeInnerOneTimeEvent : VerificationCodeScreenEvents() +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt b/registration/lib/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt new file mode 100644 index 0000000000..eb93ce8755 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeState.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.verificationcode + +import org.signal.registration.NetworkController.SessionMetadata +import kotlin.time.Duration + +data class VerificationCodeState( + val sessionMetadata: SessionMetadata? = null, + val e164: String = "", + val oneTimeEvent: OneTimeEvent? = null +) { + sealed interface OneTimeEvent { + data object NetworkError : OneTimeEvent + data object UnknownError : OneTimeEvent + data class RateLimited(val retryAfter: Duration) : OneTimeEvent + data object ThirdPartyError : OneTimeEvent + data object CouldNotRequestCodeWithSelectedTransport : OneTimeEvent + data object IncorrectVerificationCode : OneTimeEvent + data object RegistrationError : OneTimeEvent + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt b/registration/lib/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt new file mode 100644 index 0000000000..e1cb70b2d4 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt @@ -0,0 +1,236 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.verificationcode + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.signal.core.util.logging.Log +import org.signal.registration.NetworkController +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.util.navigateBack +import org.signal.registration.screens.util.navigateTo +import org.signal.registration.screens.verificationcode.VerificationCodeState.OneTimeEvent + +class VerificationCodeViewModel( + private val repository: RegistrationRepository, + private val parentState: StateFlow, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit +) : ViewModel() { + + companion object { + private val TAG = Log.tag(VerificationCodeViewModel::class) + } + + private val _localState = MutableStateFlow(VerificationCodeState()) + val state = combine(_localState, parentState) { state, parentState -> applyParentState(state, parentState) } + .onEach { Log.d(TAG, "[State] $it") } + .stateIn(viewModelScope, SharingStarted.Eagerly, VerificationCodeState()) + + fun onEvent(event: VerificationCodeScreenEvents) { + viewModelScope.launch { + _localState.emit(applyEvent(state.value, event)) + } + } + + suspend fun applyEvent(state: VerificationCodeState, event: VerificationCodeScreenEvents): VerificationCodeState { + return when (event) { + is VerificationCodeScreenEvents.CodeEntered -> transformCodeEntered(state, event.code) + is VerificationCodeScreenEvents.WrongNumber -> state.also { parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberEntry) } + is VerificationCodeScreenEvents.ResendSms -> transformResendCode(state, NetworkController.VerificationCodeTransport.SMS) + is VerificationCodeScreenEvents.CallMe -> transformResendCode(state, NetworkController.VerificationCodeTransport.VOICE) + is VerificationCodeScreenEvents.HavingTrouble -> TODO("having trouble flow") + is VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent -> state.copy(oneTimeEvent = null) + } + } + + fun applyParentState(state: VerificationCodeState, parentState: RegistrationFlowState): VerificationCodeState { + if (parentState.sessionMetadata == null || parentState.sessionE164 == null) { + Log.w(TAG, "Parent state is missing session metadata or e164! Resetting.") + parentEventEmitter(RegistrationFlowEvent.ResetState) + return state + } + + return state.copy( + sessionMetadata = parentState.sessionMetadata, + e164 = parentState.sessionE164 + ) + } + + private suspend fun transformCodeEntered(inputState: VerificationCodeState, code: String): VerificationCodeState { + var state = inputState.copy() + var sessionMetadata = state.sessionMetadata ?: return state.copy(oneTimeEvent = OneTimeEvent.UnknownError) + + // TODO should we be checking on whether we need to do more captcha stuff? + + val result = repository.submitVerificationCode(sessionMetadata.id, code) + + sessionMetadata = when (result) { + is NetworkController.RegistrationNetworkResult.Success -> { + result.data + } + is NetworkController.RegistrationNetworkResult.Failure -> { + when (result.error) { + is NetworkController.SubmitVerificationCodeError.IncorrectVerificationCode -> { + Log.w(TAG, "[SubmitCode] Incorrect verification code entered. Body: ${result.error.message}") + return state.copy(oneTimeEvent = OneTimeEvent.IncorrectVerificationCode) + } + is NetworkController.SubmitVerificationCodeError.SessionNotFound -> { + Log.w(TAG, "[SubmitCode] Session not found: ${result.error.message}") + parentEventEmitter(RegistrationFlowEvent.ResetState) + return state + } + is NetworkController.SubmitVerificationCodeError.SessionAlreadyVerifiedOrNoCodeRequested -> { + if (result.error.session.verified) { + Log.i(TAG, "[SubmitCode] Session already had number verified, continuing with registration.") + result.error.session + } else { + Log.w(TAG, "[SubmitCode] No code was requested for this session? Need to have user re-submit.") + parentEventEmitter.navigateBack() + return state + } + } + is NetworkController.SubmitVerificationCodeError.RateLimited -> { + Log.w(TAG, "[SubmitCode] Rate limited.") + return state.copy(oneTimeEvent = OneTimeEvent.RateLimited(result.error.retryAfter)) + } + } + } + is NetworkController.RegistrationNetworkResult.NetworkError -> { + return state.copy(oneTimeEvent = OneTimeEvent.NetworkError) + } + is NetworkController.RegistrationNetworkResult.ApplicationError -> { + Log.w(TAG, "Unknown error when submitting verification code.", result.exception) + return state.copy(oneTimeEvent = OneTimeEvent.UnknownError) + } + } + + state = state.copy(sessionMetadata = sessionMetadata) + + // Attempt to register + val registerResult = repository.registerAccount(e164 = state.e164, sessionId = sessionMetadata.id, skipDeviceTransfer = true) + + return when (registerResult) { + is NetworkController.RegistrationNetworkResult.Success -> { + parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete(registerResult.data)) + state + } + is NetworkController.RegistrationNetworkResult.Failure -> { + when (registerResult.error) { + is NetworkController.RegisterAccountError.DeviceTransferPossible -> { + Log.w(TAG, "[Register] Got told a device transfer is possible. We should never get into this state. Resetting.") + parentEventEmitter(RegistrationFlowEvent.ResetState) + state + } + is NetworkController.RegisterAccountError.RegistrationLock -> { + Log.w(TAG, "[Register] Reglocked.") + TODO("reglock") + } + is NetworkController.RegisterAccountError.RateLimited -> { + Log.w(TAG, "[Register] Rate limited.") + state.copy(oneTimeEvent = OneTimeEvent.RateLimited(registerResult.error.retryAfter)) + } + is NetworkController.RegisterAccountError.InvalidRequest -> { + Log.w(TAG, "[Register] Invalid request when registering account: ${registerResult.error.message}") + state.copy(oneTimeEvent = OneTimeEvent.RegistrationError) + } + is NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> { + Log.w(TAG, "[Register] Registration recovery password incorrect: ${registerResult.error.message}") + state.copy(oneTimeEvent = OneTimeEvent.RegistrationError) + } + } + } + is NetworkController.RegistrationNetworkResult.NetworkError -> { + Log.w(TAG, "[Register] Network error.", registerResult.exception) + state.copy(oneTimeEvent = OneTimeEvent.NetworkError) + } + is NetworkController.RegistrationNetworkResult.ApplicationError -> { + Log.w(TAG, "[Register] Unknown error when registering account.", registerResult.exception) + state.copy(oneTimeEvent = OneTimeEvent.UnknownError) + } + } + } + + private suspend fun transformResendCode( + inputState: VerificationCodeState, + transport: NetworkController.VerificationCodeTransport + ): VerificationCodeState { + val state = inputState.copy() + if (state.sessionMetadata == null) { + parentEventEmitter(RegistrationFlowEvent.ResetState) + return inputState + } + + val sessionMetadata = state.sessionMetadata + + val result = repository.requestVerificationCode( + sessionId = sessionMetadata.id, + smsAutoRetrieveCodeSupported = false, + transport = transport + ) + + return when (result) { + is NetworkController.RegistrationNetworkResult.Success -> { + state.copy(sessionMetadata = result.data) + } + is NetworkController.RegistrationNetworkResult.Failure -> { + when (result.error) { + is NetworkController.RequestVerificationCodeError.InvalidRequest -> { + state.copy(oneTimeEvent = OneTimeEvent.UnknownError) + } + is NetworkController.RequestVerificationCodeError.RateLimited -> { + state.copy(oneTimeEvent = OneTimeEvent.RateLimited(result.error.retryAfter)) + } + is NetworkController.RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport -> { + state.copy(oneTimeEvent = OneTimeEvent.CouldNotRequestCodeWithSelectedTransport) + } + is NetworkController.RequestVerificationCodeError.InvalidSessionId -> { + parentEventEmitter(RegistrationFlowEvent.ResetState) + state + } + is NetworkController.RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified -> { + Log.w(TAG, "When requesting verification code, missing request information or already verified.") + state.copy(oneTimeEvent = OneTimeEvent.NetworkError) + } + is NetworkController.RequestVerificationCodeError.SessionNotFound -> { + parentEventEmitter(RegistrationFlowEvent.ResetState) + state + } + is NetworkController.RequestVerificationCodeError.ThirdPartyServiceError -> { + state.copy(oneTimeEvent = OneTimeEvent.ThirdPartyError) + } + } + } + is NetworkController.RegistrationNetworkResult.NetworkError -> { + state.copy(oneTimeEvent = OneTimeEvent.NetworkError) + } + is NetworkController.RegistrationNetworkResult.ApplicationError -> { + Log.w(TAG, "Unknown error when requesting verification code.", result.exception) + state.copy(oneTimeEvent = OneTimeEvent.UnknownError) + } + } + } + + class Factory( + private val repository: RegistrationRepository, + private val parentState: StateFlow, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return VerificationCodeViewModel(repository, parentState, parentEventEmitter) as T + } + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt b/registration/lib/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt new file mode 100644 index 0000000000..2fddf6623f --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt @@ -0,0 +1,184 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.signal.registration.screens.welcome + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import org.signal.core.ui.compose.BottomSheets +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.dismissWithAnimation +import org.signal.registration.test.TestTags + +/** + * Welcome screen for the registration flow. + * This is the initial screen users see when starting the registration process. + */ +@Composable +fun WelcomeScreen( + onEvent: (WelcomeScreenEvents) -> Unit, + modifier: Modifier = Modifier +) { + var showBottomSheet by remember { mutableStateOf(false) } + + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Welcome to Signal", + style = MaterialTheme.typography.headlineLarge, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(48.dp)) + + Button( + onClick = { onEvent(WelcomeScreenEvents.Continue) }, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.WELCOME_GET_STARTED_BUTTON) + ) { + Text("Get Started") + } + OutlinedButton( + onClick = { showBottomSheet = true }, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON) + ) { + Text("Restore or transfer") + } + } + + if (showBottomSheet) { + RestoreOrTransferBottomSheet( + onEvent = { + showBottomSheet = false + onEvent(it) + }, + onDismiss = { showBottomSheet = false } + ) + } +} + +/** + * Bottom sheet for restore or transfer options. + */ +@Composable +private fun RestoreOrTransferBottomSheet( + onEvent: (WelcomeScreenEvents) -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() + + BottomSheets.BottomSheet( + onDismissRequest = { sheetState.dismissWithAnimation(scope, onComplete = onDismiss) }, + sheetState = sheetState + ) { + RestoreOrTransferBottomSheetContent( + sheetState = sheetState, + onEvent = onEvent, + scope = scope + ) + } +} + +/** + * Bottom sheet content for restore or transfer options (needs to be separate for preview). + */ +@Composable +private fun RestoreOrTransferBottomSheetContent( + sheetState: SheetState, + scope: CoroutineScope, + onEvent: (WelcomeScreenEvents) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = { + sheetState.dismissWithAnimation(scope) { + onEvent(WelcomeScreenEvents.HasOldPhone) + } + }, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.WELCOME_RESTORE_HAS_OLD_PHONE_BUTTON) + ) { + Text("I have my old phone") + } + + Button( + onClick = { + onEvent(WelcomeScreenEvents.DoesNotHaveOldPhone) + sheetState.dismissWithAnimation(scope) { + onEvent(WelcomeScreenEvents.DoesNotHaveOldPhone) + } + }, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.WELCOME_RESTORE_NO_OLD_PHONE_BUTTON) + ) { + Text("I don't have my old phone") + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@DayNightPreviews +@Composable +private fun WelcomeScreenPreview() { + Previews.Preview { + WelcomeScreen(onEvent = {}) + } +} + +@DayNightPreviews +@Composable +private fun RestoreOrTransferBottomSheetPreview() { + Previews.BottomSheetPreview(forceRtl = true) { + RestoreOrTransferBottomSheetContent( + sheetState = rememberModalBottomSheetState(), + scope = rememberCoroutineScope(), + onEvent = {} + ) + } +} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenEvents.kt b/registration/lib/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenEvents.kt new file mode 100644 index 0000000000..32ec21bf46 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenEvents.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.welcome + +sealed class WelcomeScreenEvents { + data object Continue : WelcomeScreenEvents() + data object HasOldPhone : WelcomeScreenEvents() + data object DoesNotHaveOldPhone : WelcomeScreenEvents() +} diff --git a/registration/lib/src/main/java/org/signal/registration/test/TestTags.kt b/registration/lib/src/main/java/org/signal/registration/test/TestTags.kt new file mode 100644 index 0000000000..47a9030889 --- /dev/null +++ b/registration/lib/src/main/java/org/signal/registration/test/TestTags.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.test + +/** + * Test tags for Compose UI testing. + */ +object TestTags { + + // Welcome Screen + const val WELCOME_GET_STARTED_BUTTON = "welcome_get_started_button" + const val WELCOME_RESTORE_OR_TRANSFER_BUTTON = "welcome_restore_or_transfer_button" + const val WELCOME_RESTORE_HAS_OLD_PHONE_BUTTON = "welcome_restore_has_old_phone_button" + const val WELCOME_RESTORE_NO_OLD_PHONE_BUTTON = "welcome_restore_no_old_phone_button" + + // Permissions Screen + const val PERMISSIONS_NEXT_BUTTON = "permissions_next_button" + const val PERMISSIONS_NOT_NOW_BUTTON = "permissions_not_now_button" + + // Phone Number Screen + const val PHONE_NUMBER_COUNTRY_PICKER = "phone_number_country_picker" + const val PHONE_NUMBER_COUNTRY_CODE_FIELD = "phone_number_country_code_field" + const val PHONE_NUMBER_PHONE_FIELD = "phone_number_phone_field" + const val PHONE_NUMBER_NEXT_BUTTON = "phone_number_next_button" + + // Verification Code Screen + const val VERIFICATION_CODE_INPUT = "verification_code_input" + const val VERIFICATION_CODE_DIGIT_0 = "verification_code_digit_0" + const val VERIFICATION_CODE_DIGIT_1 = "verification_code_digit_1" + const val VERIFICATION_CODE_DIGIT_2 = "verification_code_digit_2" + const val VERIFICATION_CODE_DIGIT_3 = "verification_code_digit_3" + const val VERIFICATION_CODE_DIGIT_4 = "verification_code_digit_4" + const val VERIFICATION_CODE_DIGIT_5 = "verification_code_digit_5" + const val VERIFICATION_CODE_WRONG_NUMBER_BUTTON = "verification_code_wrong_number_button" + const val VERIFICATION_CODE_RESEND_SMS_BUTTON = "verification_code_resend_sms_button" + const val VERIFICATION_CODE_CALL_ME_BUTTON = "verification_code_call_me_button" +} diff --git a/registration/lib/src/test/java/org/signal/registration/RegistrationNavigationTest.kt b/registration/lib/src/test/java/org/signal/registration/RegistrationNavigationTest.kt new file mode 100644 index 0000000000..26c1f961c4 --- /dev/null +++ b/registration/lib/src/test/java/org/signal/registration/RegistrationNavigationTest.kt @@ -0,0 +1,201 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration + +import android.app.Application +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.lifecycle.SavedStateHandle +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import io.mockk.mockk +import org.junit.Before +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.compose.theme.SignalTheme +import org.signal.registration.screens.util.MockMultiplePermissionsState +import org.signal.registration.screens.util.MockPermissionsState +import org.signal.registration.test.TestTags + +/** + * Tests for registration navigation flow using Navigation 3. + * Tests navigation by verifying UI state changes rather than using NavController. + */ +@OptIn(ExperimentalPermissionsApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class RegistrationNavigationTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var viewModel: RegistrationViewModel + private lateinit var mockRepository: RegistrationRepository + + @Before + fun setup() { + mockRepository = mockk(relaxed = true) + viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + } + + @Test + fun `navigation starts at Welcome screen`() { + // Given + val permissionsState = createMockPermissionsState() + + composeTestRule.setContent { + SignalTheme(incognitoKeyboardEnabled = false) { + RegistrationNavHost( + registrationRepository = mockRepository, + registrationViewModel = viewModel, + permissionsState = permissionsState + ) + } + } + + // Then - verify Welcome screen is displayed + composeTestRule.onNodeWithText("Welcome to Signal").assertIsDisplayed() + } + + @Test + fun `clicking Get Started navigates from Welcome to Permissions`() { + // Given + val permissionsState = createMockPermissionsState() + + composeTestRule.setContent { + SignalTheme { + RegistrationNavHost( + registrationRepository = mockRepository, + registrationViewModel = viewModel, + permissionsState = permissionsState + ) + } + } + + // When + composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).performClick() + + // Then - verify Permissions screen is displayed + composeTestRule.onNodeWithText("Permissions").assertIsDisplayed() + } + + @Test + fun `clicking Next on Permissions navigates to PhoneNumber`() { + // Given + val permissionsState = createMockPermissionsState() + + composeTestRule.setContent { + SignalTheme { + RegistrationNavHost( + registrationRepository = mockRepository, + registrationViewModel = viewModel, + permissionsState = permissionsState + ) + } + } + + // Navigate to Permissions screen first + composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).performClick() + + // When + composeTestRule.onNodeWithTag(TestTags.PERMISSIONS_NEXT_BUTTON).performClick() + + // Then - verify PhoneNumber screen is displayed + composeTestRule.onNodeWithText("You will receive a verification code").assertIsDisplayed() + } + + @Test + fun `clicking Not now on Permissions navigates to PhoneNumber`() { + // Given + val permissionsState = createMockPermissionsState() + + composeTestRule.setContent { + SignalTheme { + RegistrationNavHost( + registrationRepository = mockRepository, + registrationViewModel = viewModel, + permissionsState = permissionsState + ) + } + } + + // Navigate to Permissions screen first + composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).performClick() + + // When + composeTestRule.onNodeWithTag(TestTags.PERMISSIONS_NOT_NOW_BUTTON).performClick() + + // Then - verify PhoneNumber screen is displayed + composeTestRule.onNodeWithText("You will receive a verification code").assertIsDisplayed() + } + + // Note: Back navigation testing in Navigation 3 requires testing through + // actual back button presses at the Activity level, which is better suited + // for instrumentation tests. The back stack is managed internally by Nav3 + // and not directly accessible in unit tests. + + @Test + fun `clicking I have my old phone navigates to Permissions for restore`() { + // Given + val permissionsState = createMockPermissionsState() + + composeTestRule.setContent { + SignalTheme { + RegistrationNavHost( + registrationRepository = mockRepository, + registrationViewModel = viewModel, + permissionsState = permissionsState + ) + } + } + + // When + composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).performClick() + composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_HAS_OLD_PHONE_BUTTON).performClick() + + // Then - verify Permissions screen is displayed + // (After permissions, user would go to RestoreViaQr screen) + composeTestRule.onNodeWithText("Permissions").assertIsDisplayed() + } + + @Test + fun `clicking I don't have my old phone navigates to Restore`() { + // Given + val permissionsState = createMockPermissionsState() + + composeTestRule.setContent { + SignalTheme { + RegistrationNavHost( + registrationRepository = mockRepository, + registrationViewModel = viewModel, + permissionsState = permissionsState + ) + } + } + + // When + composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).performClick() + composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_NO_OLD_PHONE_BUTTON).performClick() + + // Then - verify Restore screen is displayed (or its expected content) + // Note: Update this assertion based on actual Restore screen content when implemented + } + + /** + * Creates a mock permissions state for testing. + * Since we're in JUnit tests, we can't use the real rememberMultiplePermissionsState. + */ + private fun createMockPermissionsState(): MockMultiplePermissionsState { + return MockMultiplePermissionsState( + permissions = viewModel.getRequiredPermissions().map { MockPermissionsState(it) } + ) + } +} diff --git a/registration/lib/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt b/registration/lib/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt new file mode 100644 index 0000000000..9839bbaab6 --- /dev/null +++ b/registration/lib/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt @@ -0,0 +1,558 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.phonenumber + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.prop +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.signal.registration.NetworkController +import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationFlowState +import org.signal.registration.RegistrationRepository +import org.signal.registration.RegistrationRoute +import kotlin.time.Duration.Companion.seconds + +class PhoneNumberEntryViewModelTest { + + private lateinit var viewModel: PhoneNumberEntryViewModel + private lateinit var mockRepository: RegistrationRepository + private lateinit var parentState: MutableStateFlow + private lateinit var emittedEvents: MutableList + private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit + + @Before + fun setup() { + mockRepository = mockk(relaxed = true) + parentState = MutableStateFlow(RegistrationFlowState()) + emittedEvents = mutableListOf() + parentEventEmitter = { event -> emittedEvents.add(event) } + viewModel = PhoneNumberEntryViewModel(mockRepository, parentState, parentEventEmitter) + } + + @Test + fun `initial state has default US region and country code`() { + val state = PhoneNumberEntryState() + + assertThat(state.regionCode).isEqualTo("US") + assertThat(state.countryCode).isEqualTo("1") + assertThat(state.nationalNumber).isEqualTo("") + assertThat(state.formattedNumber).isEqualTo("") + } + + @Test + fun `PhoneNumberChanged extracts digits and formats number`() = runTest { + val initialState = PhoneNumberEntryState() + + val result = viewModel.applyEvent( + initialState, + PhoneNumberEntryScreenEvents.PhoneNumberChanged("555-123-4567") + ) + + assertThat(result.nationalNumber).isEqualTo("5551234567") + assertThat(result.formattedNumber).isEqualTo("(555) 123-4567") + } + + @Test + fun `PhoneNumberChanged with raw digits formats correctly`() = runTest { + val initialState = PhoneNumberEntryState() + + val result = viewModel.applyEvent( + initialState, + PhoneNumberEntryScreenEvents.PhoneNumberChanged("5551234567") + ) + + assertThat(result.nationalNumber).isEqualTo("5551234567") + assertThat(result.formattedNumber).isEqualTo("(555) 123-4567") + } + + @Test + fun `PhoneNumberChanged formats progressively as digits are added`() = runTest { + var state = PhoneNumberEntryState() + + state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5")) + assertThat(state.nationalNumber).isEqualTo("5") + + state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("55")) + assertThat(state.nationalNumber).isEqualTo("55") + + state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("555")) + assertThat(state.nationalNumber).isEqualTo("555") + + state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5551")) + assertThat(state.nationalNumber).isEqualTo("5551") + // libphonenumber formats progressively - at 4 digits it's still building the format + assertThat(state.formattedNumber).isEqualTo("555-1") + + state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("55512")) + assertThat(state.nationalNumber).isEqualTo("55512") + assertThat(state.formattedNumber).isEqualTo("555-12") + } + + @Test + fun `PhoneNumberChanged ignores non-digit characters`() = runTest { + val initialState = PhoneNumberEntryState() + + val result = viewModel.applyEvent( + initialState, + PhoneNumberEntryScreenEvents.PhoneNumberChanged("(555) abc 123-4567!") + ) + + assertThat(result.nationalNumber).isEqualTo("5551234567") + } + + @Test + fun `PhoneNumberChanged with same digits returns same state`() = runTest { + val initialState = PhoneNumberEntryState(nationalNumber = "5551234567", formattedNumber = "(555) 123-4567") + + val result = viewModel.applyEvent( + initialState, + PhoneNumberEntryScreenEvents.PhoneNumberChanged("555-123-4567") + ) + + // Should return the same state object since digits haven't changed + assertThat(result).isEqualTo(initialState) + } + + @Test + fun `CountryCodeChanged updates country code and region`() = runTest { + val initialState = PhoneNumberEntryState() + + val result = viewModel.applyEvent( + initialState, + PhoneNumberEntryScreenEvents.CountryCodeChanged("44") + ) + + assertThat(result.countryCode).isEqualTo("44") + assertThat(result.regionCode).isEqualTo("GB") + } + + @Test + fun `CountryCodeChanged sanitizes input to digits only`() = runTest { + val initialState = PhoneNumberEntryState() + + val result = viewModel.applyEvent( + initialState, + PhoneNumberEntryScreenEvents.CountryCodeChanged("+44abc") + ) + + assertThat(result.countryCode).isEqualTo("44") + } + + @Test + fun `CountryCodeChanged limits to 3 digits`() = runTest { + val initialState = PhoneNumberEntryState() + + val result = viewModel.applyEvent( + initialState, + PhoneNumberEntryScreenEvents.CountryCodeChanged("12345") + ) + + assertThat(result.countryCode).isEqualTo("123") + } + + @Test + fun `CountryCodeChanged reformats existing phone number for new region`() = runTest { + // Start with a US number + var state = PhoneNumberEntryState( + regionCode = "US", + countryCode = "1", + nationalNumber = "5551234567", + formattedNumber = "(555) 123-4567" + ) + + // Change to UK + state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.CountryCodeChanged("44")) + + assertThat(state.countryCode).isEqualTo("44") + assertThat(state.regionCode).isEqualTo("GB") + // The digits should be reformatted for UK format + assertThat(state.nationalNumber).isEqualTo("5551234567") + } + + @Test + fun `CountryPicker emits NavigateToCountryPicker event`() = runTest { + val initialState = PhoneNumberEntryState() + + viewModel.applyEvent( + initialState, + PhoneNumberEntryScreenEvents.CountryPicker + ) + + assertThat(emittedEvents).hasSize(1) + assertThat(emittedEvents.first()).isEqualTo( + RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.CountryCodePicker) + ) + } + + @Test + fun `ConsumeInnerOneTimeEvent clears inner event`() = runTest { + val initialState = PhoneNumberEntryState( + oneTimeEvent = PhoneNumberEntryState.OneTimeEvent.NetworkError + ) + + val result = viewModel.applyEvent( + initialState, + PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent + ) + + assertThat(result.oneTimeEvent).isNull() + } + + @Test + fun `German country code formats correctly`() = runTest { + var state = PhoneNumberEntryState() + + // Set German country code + state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.CountryCodeChanged("49")) + assertThat(state.regionCode).isEqualTo("DE") + + // Enter a German number + state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("15123456789")) + assertThat(state.nationalNumber).isEqualTo("15123456789") + } + + // ==================== PhoneNumberSubmitted Tests ==================== + + @Test + fun `PhoneNumberSubmitted creates session and requests code on success`() = runTest { + val sessionMetadata = createSessionMetadata(requestedInformation = emptyList()) + + coEvery { mockRepository.createSession(any()) } returns + NetworkController.RegistrationNetworkResult.Success(sessionMetadata) + coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns + NetworkController.RegistrationNetworkResult.Success(sessionMetadata) + + val initialState = PhoneNumberEntryState( + countryCode = "1", + nationalNumber = "5551234567" + ) + + val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) + + assertThat(result.sessionMetadata).isNotNull() + assertThat(emittedEvents).hasSize(1) + assertThat(emittedEvents.first()) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isInstanceOf() + } + + @Test + fun `PhoneNumberSubmitted navigates to captcha when required`() = runTest { + val sessionMetadata = createSessionMetadata(requestedInformation = listOf("captcha")) + + coEvery { mockRepository.createSession(any()) } returns + NetworkController.RegistrationNetworkResult.Success(sessionMetadata) + + val initialState = PhoneNumberEntryState( + countryCode = "1", + nationalNumber = "5551234567" + ) + + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) + + assertThat(emittedEvents).hasSize(1) + assertThat(emittedEvents.first()) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isInstanceOf() + } + + @Test + fun `PhoneNumberSubmitted handles rate limiting from createSession`() = runTest { + coEvery { mockRepository.createSession(any()) } returns + NetworkController.RegistrationNetworkResult.Failure( + NetworkController.CreateSessionError.RateLimited(60.seconds) + ) + + val initialState = PhoneNumberEntryState( + countryCode = "1", + nationalNumber = "5551234567" + ) + + val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) + + assertThat(result.oneTimeEvent).isNotNull() + .isInstanceOf() + .prop(PhoneNumberEntryState.OneTimeEvent.RateLimited::retryAfter) + .isEqualTo(60.seconds) + } + + @Test + fun `PhoneNumberSubmitted handles invalid request from createSession`() = runTest { + coEvery { mockRepository.createSession(any()) } returns + NetworkController.RegistrationNetworkResult.Failure( + NetworkController.CreateSessionError.InvalidRequest("Bad request") + ) + + val initialState = PhoneNumberEntryState( + countryCode = "1", + nationalNumber = "5551234567" + ) + + val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) + + assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError) + } + + @Test + fun `PhoneNumberSubmitted handles network error`() = runTest { + coEvery { mockRepository.createSession(any()) } returns + NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Network error")) + + val initialState = PhoneNumberEntryState( + countryCode = "1", + nationalNumber = "5551234567" + ) + + val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) + + assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError) + } + + @Test + fun `PhoneNumberSubmitted handles application error`() = runTest { + coEvery { mockRepository.createSession(any()) } returns + NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected error")) + + val initialState = PhoneNumberEntryState( + countryCode = "1", + nationalNumber = "5551234567" + ) + + val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) + + assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError) + } + + @Test + fun `PhoneNumberSubmitted reuses existing session`() = runTest { + val existingSession = createSessionMetadata(id = "existing-session") + val initialState = PhoneNumberEntryState( + countryCode = "1", + nationalNumber = "5551234567", + sessionMetadata = existingSession + ) + + coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns + NetworkController.RegistrationNetworkResult.Success(existingSession) + + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) + + // Should not create a new session, just request verification code + assertThat(emittedEvents).hasSize(1) + assertThat(emittedEvents.first()) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isInstanceOf() + } + + @Test + fun `PhoneNumberSubmitted handles rate limiting from requestVerificationCode`() = runTest { + val sessionMetadata = createSessionMetadata() + + coEvery { mockRepository.createSession(any()) } returns + NetworkController.RegistrationNetworkResult.Success(sessionMetadata) + coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns + NetworkController.RegistrationNetworkResult.Failure( + NetworkController.RequestVerificationCodeError.RateLimited(30.seconds, sessionMetadata) + ) + + val initialState = PhoneNumberEntryState( + countryCode = "1", + nationalNumber = "5551234567" + ) + + val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) + + assertThat(result.oneTimeEvent).isNotNull().isInstanceOf() + } + + @Test + fun `PhoneNumberSubmitted handles session not found`() = runTest { + val sessionMetadata = createSessionMetadata() + + coEvery { mockRepository.createSession(any()) } returns + NetworkController.RegistrationNetworkResult.Success(sessionMetadata) + coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns + NetworkController.RegistrationNetworkResult.Failure( + NetworkController.RequestVerificationCodeError.SessionNotFound("Session expired") + ) + + val initialState = PhoneNumberEntryState( + countryCode = "1", + nationalNumber = "5551234567" + ) + + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) + + assertThat(emittedEvents).hasSize(1) + assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState) + } + + @Test + fun `PhoneNumberSubmitted handles transport not supported`() = runTest { + val sessionMetadata = createSessionMetadata() + + coEvery { mockRepository.createSession(any()) } returns + NetworkController.RegistrationNetworkResult.Success(sessionMetadata) + coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns + NetworkController.RegistrationNetworkResult.Failure( + NetworkController.RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport(sessionMetadata) + ) + + val initialState = PhoneNumberEntryState( + countryCode = "1", + nationalNumber = "5551234567" + ) + + val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) + + assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport) + } + + @Test + fun `PhoneNumberSubmitted handles third party service error`() = runTest { + val sessionMetadata = createSessionMetadata() + + coEvery { mockRepository.createSession(any()) } returns + NetworkController.RegistrationNetworkResult.Success(sessionMetadata) + coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns + NetworkController.RegistrationNetworkResult.Failure( + NetworkController.RequestVerificationCodeError.ThirdPartyServiceError( + NetworkController.ThirdPartyServiceErrorResponse("Provider error", false) + ) + ) + + val initialState = PhoneNumberEntryState( + countryCode = "1", + nationalNumber = "5551234567" + ) + + val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) + + assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.ThirdPartyError) + } + + // ==================== CaptchaCompleted Tests ==================== + + @Test + fun `CaptchaCompleted submits token and navigates to verification code`() = runTest { + val sessionMetadata = createSessionMetadata(requestedInformation = emptyList()) + val initialState = PhoneNumberEntryState(sessionMetadata = sessionMetadata) + + coEvery { mockRepository.submitCaptchaToken(any(), any()) } returns + NetworkController.RegistrationNetworkResult.Success(sessionMetadata) + coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns + NetworkController.RegistrationNetworkResult.Success(sessionMetadata) + + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token")) + + assertThat(emittedEvents).hasSize(1) + assertThat(emittedEvents.first()) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isInstanceOf() + } + + @Test + fun `CaptchaCompleted returns error when no session exists`() = runTest { + val initialState = PhoneNumberEntryState(sessionMetadata = null) + + val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token")) + + assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError) + } + + @Test + fun `CaptchaCompleted handles captcha still required after submission`() = runTest { + val sessionWithCaptcha = createSessionMetadata(requestedInformation = listOf("captcha")) + val initialState = PhoneNumberEntryState(sessionMetadata = sessionWithCaptcha) + + coEvery { mockRepository.submitCaptchaToken(any(), any()) } returns + NetworkController.RegistrationNetworkResult.Success(sessionWithCaptcha) + + viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token")) + + assertThat(emittedEvents).hasSize(1) + assertThat(emittedEvents.first()) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isInstanceOf() + } + + @Test + fun `CaptchaCompleted handles rate limiting`() = runTest { + val sessionMetadata = createSessionMetadata() + val initialState = PhoneNumberEntryState(sessionMetadata = sessionMetadata) + + coEvery { mockRepository.submitCaptchaToken(any(), any()) } returns + NetworkController.RegistrationNetworkResult.Failure( + NetworkController.UpdateSessionError.RateLimited(45.seconds, sessionMetadata) + ) + + val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token")) + + assertThat(result.oneTimeEvent).isNotNull() + .isInstanceOf() + .prop(PhoneNumberEntryState.OneTimeEvent.RateLimited::retryAfter) + .isEqualTo(45.seconds) + } + + @Test + fun `CaptchaCompleted handles rejected update`() = runTest { + val sessionMetadata = createSessionMetadata() + val initialState = PhoneNumberEntryState(sessionMetadata = sessionMetadata) + + coEvery { mockRepository.submitCaptchaToken(any(), any()) } returns + NetworkController.RegistrationNetworkResult.Failure( + NetworkController.UpdateSessionError.RejectedUpdate("Invalid captcha") + ) + + val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token")) + + assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError) + } + + @Test + fun `CaptchaCompleted handles network error`() = runTest { + val sessionMetadata = createSessionMetadata() + val initialState = PhoneNumberEntryState(sessionMetadata = sessionMetadata) + + coEvery { mockRepository.submitCaptchaToken(any(), any()) } returns + NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Connection lost")) + + val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token")) + + assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError) + } + + // ==================== Helper Functions ==================== + + private fun createSessionMetadata( + id: String = "test-session-id", + requestedInformation: List = emptyList(), + verified: Boolean = false + ) = NetworkController.SessionMetadata( + id = id, + nextSms = null, + nextCall = null, + nextVerificationAttempt = null, + allowedToRequestCode = true, + requestedInformation = requestedInformation, + verified = verified + ) +} diff --git a/registration/lib/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberScreenTest.kt b/registration/lib/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberScreenTest.kt new file mode 100644 index 0000000000..f1e55a5542 --- /dev/null +++ b/registration/lib/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberScreenTest.kt @@ -0,0 +1,121 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.phonenumber + +import android.app.Application +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +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.compose.theme.SignalTheme +import org.signal.registration.test.TestTags + +/** + * Tests for PhoneNumberScreen that validate user interactions and event emissions. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class PhoneNumberScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `Next button is disabled when fields are empty`() { + // Given + composeTestRule.setContent { + SignalTheme { + PhoneNumberScreen( + state = PhoneNumberEntryState(), + onEvent = {} + ) + } + } + + // Then + composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_NEXT_BUTTON).assertIsNotEnabled() + } + + @Test + fun `Next button is enabled when nationalNumber is present in state`() { + // Given + composeTestRule.setContent { + SignalTheme { + PhoneNumberScreen( + state = PhoneNumberEntryState( + countryCode = "1", + nationalNumber = "5551234567", + formattedNumber = "(555) 123-4567" + ), + onEvent = {} + ) + } + } + + // Then + composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_NEXT_BUTTON).assertIsEnabled() + } + + @Test + fun `when Next is clicked, PhoneNumberSubmitted event is emitted`() { + // Given + var emittedEvent: PhoneNumberEntryScreenEvents? = null + + composeTestRule.setContent { + SignalTheme { + PhoneNumberScreen( + state = PhoneNumberEntryState( + countryCode = "1", + nationalNumber = "5551234567", + formattedNumber = "(555) 123-4567" + ), + onEvent = { event -> + emittedEvent = event + } + ) + } + } + + // When - click Next + composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_NEXT_BUTTON).performClick() + + // Then + assert(emittedEvent is PhoneNumberEntryScreenEvents.PhoneNumberSubmitted) { + "Expected PhoneNumberSubmitted event but got $emittedEvent" + } + } + + @Test + fun `clicking country picker emits CountryPicker event`() { + // Given + var emittedEvent: PhoneNumberEntryScreenEvents? = null + + composeTestRule.setContent { + SignalTheme { + PhoneNumberScreen( + state = PhoneNumberEntryState(), + onEvent = { event -> + emittedEvent = event + } + ) + } + } + + // When + composeTestRule.onNodeWithTag(TestTags.PHONE_NUMBER_COUNTRY_PICKER).performClick() + + // Then + assert(emittedEvent is PhoneNumberEntryScreenEvents.CountryPicker) { + "Expected CountryPicker event but got $emittedEvent" + } + } +} diff --git a/registration/lib/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenTest.kt b/registration/lib/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenTest.kt new file mode 100644 index 0000000000..2b780192b7 --- /dev/null +++ b/registration/lib/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenTest.kt @@ -0,0 +1,192 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.verificationcode + +import android.app.Application +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +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.compose.theme.SignalTheme +import org.signal.registration.test.TestTags + +/** + * Tests for VerificationCodeScreen that validate event emissions and UI behavior. + * Uses Robolectric to run fast JUnit tests without an emulator. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class VerificationCodeScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `screen displays title`() { + // Given + composeTestRule.setContent { + SignalTheme { + VerificationCodeScreen( + state = VerificationCodeState(), + onEvent = {} + ) + } + } + + // Then + composeTestRule.onNodeWithText("Enter verification code").assertIsDisplayed() + } + + @Test + fun `screen displays all six digit fields`() { + // Given + composeTestRule.setContent { + SignalTheme { + VerificationCodeScreen( + state = VerificationCodeState(), + onEvent = {} + ) + } + } + + // Then + composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_0).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_1).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_2).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_3).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_4).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_5).assertIsDisplayed() + } + + @Test + fun `clicking wrong number emits WrongNumber event`() { + // Given + var emittedEvent: VerificationCodeScreenEvents? = null + + composeTestRule.setContent { + SignalTheme { + VerificationCodeScreen( + state = VerificationCodeState(), + onEvent = { event -> + emittedEvent = event + } + ) + } + } + + // When + composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_WRONG_NUMBER_BUTTON).performClick() + + // Then + assert(emittedEvent == VerificationCodeScreenEvents.WrongNumber) + } + + @Test + fun `clicking resend SMS emits ResendSms event`() { + // Given + var emittedEvent: VerificationCodeScreenEvents? = null + + composeTestRule.setContent { + SignalTheme { + VerificationCodeScreen( + state = VerificationCodeState(), + onEvent = { event -> + emittedEvent = event + } + ) + } + } + + // When + composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_RESEND_SMS_BUTTON).performClick() + + // Then + assert(emittedEvent == VerificationCodeScreenEvents.ResendSms) + } + + @Test + fun `clicking call me emits CallMe event`() { + // Given + var emittedEvent: VerificationCodeScreenEvents? = null + + composeTestRule.setContent { + SignalTheme { + VerificationCodeScreen( + state = VerificationCodeState(), + onEvent = { event -> + emittedEvent = event + } + ) + } + } + + // When + composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_CALL_ME_BUTTON).performClick() + + // Then + assert(emittedEvent == VerificationCodeScreenEvents.CallMe) + } + + @Test + fun `entering complete code emits CodeEntered event`() { + // Given + var emittedEvent: VerificationCodeScreenEvents? = null + + composeTestRule.setContent { + SignalTheme { + VerificationCodeScreen( + state = VerificationCodeState(), + onEvent = { event -> + emittedEvent = event + } + ) + } + } + + // When - enter all 6 digits + composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_0).performTextInput("1") + composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_1).performTextInput("2") + composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_2).performTextInput("3") + composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_3).performTextInput("4") + composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_4).performTextInput("5") + composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_5).performTextInput("6") + + composeTestRule.waitForIdle() + + // Then + assert(emittedEvent is VerificationCodeScreenEvents.CodeEntered) { + "Expected CodeEntered event but got $emittedEvent" + } + assert((emittedEvent as VerificationCodeScreenEvents.CodeEntered).code == "123456") { + "Expected code '123456' but got ${(emittedEvent as VerificationCodeScreenEvents.CodeEntered).code}" + } + } + + @Test + fun `screen displays all action buttons`() { + // Given + composeTestRule.setContent { + SignalTheme { + VerificationCodeScreen( + state = VerificationCodeState(), + onEvent = {} + ) + } + } + + // Then + composeTestRule.onNodeWithText("Wrong number?").assertIsDisplayed() + composeTestRule.onNodeWithText("Resend SMS").assertIsDisplayed() + composeTestRule.onNodeWithText("Call me instead").assertIsDisplayed() + } +} diff --git a/registration/lib/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenTest.kt b/registration/lib/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenTest.kt new file mode 100644 index 0000000000..bfee89f2f3 --- /dev/null +++ b/registration/lib/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenTest.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.welcome + +import android.app.Application +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +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.compose.theme.SignalTheme +import org.signal.registration.test.TestTags + +/** + * Tests for WelcomeScreen that validate event emissions. + * Uses Robolectric to run fast JUnit tests without an emulator. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class WelcomeScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `when Get Started is clicked, Continue event is emitted`() { + // Given + var emittedEvent: WelcomeScreenEvents? = null + + composeTestRule.setContent { + SignalTheme { + WelcomeScreen( + onEvent = { event -> + emittedEvent = event + } + ) + } + } + + // When + composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).performClick() + + // Then + assert(emittedEvent == WelcomeScreenEvents.Continue) + } + + @Test + fun `when Restore or transfer is clicked, bottom sheet is shown`() { + // Given + composeTestRule.setContent { + SignalTheme { + WelcomeScreen(onEvent = {}) + } + } + + // When + composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).performClick() + + // Then - bottom sheet options should be visible + composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_HAS_OLD_PHONE_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_NO_OLD_PHONE_BUTTON).assertIsDisplayed() + } + + @Test + fun `when I have my old phone is clicked, HasOldPhone event is emitted`() { + // Given + var emittedEvent: WelcomeScreenEvents? = null + + composeTestRule.setContent { + SignalTheme { + WelcomeScreen( + onEvent = { event -> + emittedEvent = event + } + ) + } + } + + // When + composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).performClick() + composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_HAS_OLD_PHONE_BUTTON).performClick() + + // Then + assert(emittedEvent == WelcomeScreenEvents.HasOldPhone) + } + + @Test + fun `when I don't have my old phone is clicked, DoesNotHaveOldPhone event is emitted`() { + // Given + var emittedEvent: WelcomeScreenEvents? = null + + composeTestRule.setContent { + SignalTheme { + WelcomeScreen( + onEvent = { event -> + emittedEvent = event + } + ) + } + } + + // When + composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).performClick() + composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_NO_OLD_PHONE_BUTTON).performClick() + + // Then + assert(emittedEvent == WelcomeScreenEvents.DoesNotHaveOldPhone) + } + + @Test + fun `screen displays welcome message`() { + // Given + composeTestRule.setContent { + SignalTheme { + WelcomeScreen(onEvent = {}) + } + } + + // Then + composeTestRule.onNodeWithText("Welcome to Signal").assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON).assertIsDisplayed() + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index dff0aada13..febcd45a90 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -84,6 +84,8 @@ include(":microbenchmark") include(":video") include(":video-app") include(":billing") +include(":registration") +include(":registration-app") project(":app").name = "Signal-Android" project(":paging").projectDir = file("paging/lib") @@ -113,4 +115,7 @@ project(":qr-app").projectDir = file("qr/app") project(":video").projectDir = file("video/lib") project(":video-app").projectDir = file("video/app") +project(":registration").projectDir = file("registration/lib") +project(":registration-app").projectDir = file("registration/app") + rootProject.name = "Signal"