mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-15 07:28:30 +00:00
Add initial registration v5 prototype.
This commit is contained in:
committed by
jeffrey-signal
parent
1a5163fc47
commit
5ea5279fbb
@@ -109,7 +109,7 @@ fun DevicePinAuthEducationSheet(
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun DevicePinAuthEducationSheetPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
DevicePinAuthEducationSheet(
|
||||
title = "To continue, confirm it's you",
|
||||
onClick = {}
|
||||
|
||||
@@ -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) { }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -74,7 +74,7 @@ private fun NoManualBackupSheetContent(
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun NoManualBackupSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
NoManualBackupSheetContent(
|
||||
durationSinceLastBackup = 30.days
|
||||
)
|
||||
|
||||
@@ -111,7 +111,7 @@ private fun NoRemoteStorageSpaceAvailableBottomSheetContent(
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun NoRemoteStorageSpaceAvailableBottomSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
NoRemoteStorageSpaceAvailableBottomSheetContent({}, {}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,7 +521,7 @@ private fun SaveKeyConfirmationDialogPreview() {
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun CreateNewBackupKeySheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
Column {
|
||||
CreateNewBackupKeySheetContent()
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ private fun MessageBackupsKeyRecordScreenPreview() {
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BottomSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
BottomSheetContent({}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -328,7 +328,7 @@ private fun CreateCallLinkBottomSheetContent(
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun CreateCallLinkBottomSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
CreateCallLinkBottomSheetContent(
|
||||
callLink = CallLinkTable.CallLink(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
|
||||
@@ -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<Set<CallQualityIssue>>(emptySet()) }
|
||||
|
||||
Column {
|
||||
@@ -524,7 +524,7 @@ private fun WhatIssuesDidYouHavePreview() {
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun HelpUsImprovePreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
Column {
|
||||
HelpUsImprove(
|
||||
isShareDebugLogSelected = true,
|
||||
|
||||
@@ -105,7 +105,7 @@ private fun Sheet(onDismiss: () -> Unit = {}) {
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ConnectivityWarningSheetPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
Sheet()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ private fun DeviceSpecificSheet(onContinue: () -> Unit = {}, onDismiss: () -> Un
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun DeviceSpecificSheetPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
DeviceSpecificSheet()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ private fun SubscriptionNotFoundReason(text: String) {
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun SubscriptionNotFoundContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
Column {
|
||||
SubscriptionNotFoundContent()
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ fun EducationRow(text: String, painter: Painter) {
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun ChatFoldersEducationSheetPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
FolderEducationSheet(onClick = {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ class PendingParticipantsBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun PendingParticipantsSheetPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
PendingParticipantsSheet(
|
||||
pendingParticipants = listOf(
|
||||
PendingParticipantCollection.State.PENDING,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -106,7 +106,7 @@ class CallLinkIncomingRequestSheet : ComposeBottomSheetDialogFragment() {
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallLinkIncomingRequestSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
CallLinkIncomingRequestSheetContent(
|
||||
state = CallLinkIncomingRequestState(
|
||||
name = "Miles Morales",
|
||||
|
||||
@@ -266,7 +266,7 @@ private fun LegacyAudioPickerContent(
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallAudioPickerSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
Column {
|
||||
LegacyAudioPickerContent(
|
||||
toggleButtonOutputState = ToggleButtonOutputState().apply {
|
||||
|
||||
@@ -118,7 +118,7 @@ private fun MediaNoLongerAvailableBottomSheetContent(
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MediaNoLongerAvailableBottomSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
MediaNoLongerAvailableBottomSheetContent()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ fun InfoRow(text: String) {
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ProfileNameSheetPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
ProfileNameSheet()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ private fun isThreadListAlreadyAdded(folder: ChatFolderRecord, threadIds: List<L
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun AddToChatFolderSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
AddToChatFolderSheetContent(
|
||||
folders = listOf(ChatFolderRecord(name = "Friends"), ChatFolderRecord(name = "Work")),
|
||||
threadIds = listOf(1),
|
||||
|
||||
@@ -84,7 +84,7 @@ fun FinishedSheet(onClick: () -> Unit) {
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun FinishedSheetSheetPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
FinishedSheet(onClick = {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ fun EducationSheet(onClick: () -> Unit) {
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun EducationSheetPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
EducationSheet(onClick = {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ private fun LinkedDeviceInformationRow(
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun LearnMorePreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
LearnMoreSheet()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ private fun SheetOption(
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun SyncSheetSheetSheetPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
SyncSheet(onLink = {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -104,7 +104,7 @@ private fun Sheet(
|
||||
@Composable
|
||||
@DayNightPreviews
|
||||
private fun SheetPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
Sheet()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
163
core-ui/src/main/java/org/signal/core/ui/compose/QrCode.kt
Normal file
163
core-ui/src/main/java/org/signal/core/ui/compose/QrCode.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 <reified T> ResultEffect(
|
||||
resultEventBus: ResultEventBus = LocalResultEventBus.current,
|
||||
resultKey: String = T::class.toString(),
|
||||
crossinline onResult: suspend (T) -> Unit
|
||||
) {
|
||||
LaunchedEffect(resultKey, resultEventBus.channelMap[resultKey]) {
|
||||
resultEventBus.getResultFlow<T>(resultKey)?.collect { result ->
|
||||
onResult.invoke(result as T)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ResultEventBus?> =
|
||||
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<ResultEventBus?> {
|
||||
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<String, Channel<Any?>> = mutableMapOf()
|
||||
|
||||
/**
|
||||
* Provides a flow for the given resultKey.
|
||||
*/
|
||||
inline fun <reified T> getResultFlow(resultKey: String = T::class.toString()) =
|
||||
channelMap[resultKey]?.receiveAsFlow()
|
||||
|
||||
/**
|
||||
* Sends a result into the channel associated with the given resultKey.
|
||||
*/
|
||||
inline fun <reified T> 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 <reified T> removeResult(resultKey: String = T::class.toString()) {
|
||||
channelMap.remove(resultKey)
|
||||
}
|
||||
}
|
||||
43
core-ui/src/main/res/drawable/ic_keyboard_24.xml
Normal file
43
core-ui/src/main/res/drawable/ic_keyboard_24.xml
Normal file
@@ -0,0 +1,43 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M12,9.75C12.69,9.75 13.25,9.19 13.25,8.5C13.25,7.81 12.69,7.25 12,7.25C11.31,7.25 10.75,7.81 10.75,8.5C10.75,9.19 11.31,9.75 12,9.75Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M13.25,12C13.25,12.69 12.69,13.25 12,13.25C11.31,13.25 10.75,12.69 10.75,12C10.75,11.31 11.31,10.75 12,10.75C12.69,10.75 13.25,11.31 13.25,12Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M8.5,9.75C9.19,9.75 9.75,9.19 9.75,8.5C9.75,7.81 9.19,7.25 8.5,7.25C7.81,7.25 7.25,7.81 7.25,8.5C7.25,9.19 7.81,9.75 8.5,9.75Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M9.75,12C9.75,12.69 9.19,13.25 8.5,13.25C7.81,13.25 7.25,12.69 7.25,12C7.25,11.31 7.81,10.75 8.5,10.75C9.19,10.75 9.75,11.31 9.75,12Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M15.5,9.75C16.19,9.75 16.75,9.19 16.75,8.5C16.75,7.81 16.19,7.25 15.5,7.25C14.81,7.25 14.25,7.81 14.25,8.5C14.25,9.19 14.81,9.75 15.5,9.75Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M16.75,12C16.75,12.69 16.19,13.25 15.5,13.25C14.81,13.25 14.25,12.69 14.25,12C14.25,11.31 14.81,10.75 15.5,10.75C16.19,10.75 16.75,11.31 16.75,12Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M19,9.75C19.69,9.75 20.25,9.19 20.25,8.5C20.25,7.81 19.69,7.25 19,7.25C18.31,7.25 17.75,7.81 17.75,8.5C17.75,9.19 18.31,9.75 19,9.75Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M20.25,12C20.25,12.69 19.69,13.25 19,13.25C18.31,13.25 17.75,12.69 17.75,12C17.75,11.31 18.31,10.75 19,10.75C19.69,10.75 20.25,11.31 20.25,12Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M5,9.75C5.69,9.75 6.25,9.19 6.25,8.5C6.25,7.81 5.69,7.25 5,7.25C4.31,7.25 3.75,7.81 3.75,8.5C3.75,9.19 4.31,9.75 5,9.75Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M6.25,12C6.25,12.69 5.69,13.25 5,13.25C4.31,13.25 3.75,12.69 3.75,12C3.75,11.31 4.31,10.75 5,10.75C5.69,10.75 6.25,11.31 6.25,12Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M8.25,15C7.698,15 7.25,15.448 7.25,16C7.25,16.552 7.698,17 8.25,17H15.75C16.302,17 16.75,16.552 16.75,16C16.75,15.448 16.302,15 15.75,15H8.25Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M17.737,3.625H6.263C5.454,3.625 4.794,3.625 4.258,3.669C3.704,3.714 3.206,3.811 2.741,4.047C2.012,4.419 1.419,5.012 1.047,5.741C0.811,6.206 0.714,6.704 0.669,7.258C0.625,7.794 0.625,8.454 0.625,9.263V14.737C0.625,15.546 0.625,16.206 0.669,16.742C0.714,17.296 0.811,17.794 1.047,18.259C1.419,18.988 2.012,19.581 2.741,19.953C3.206,20.19 3.704,20.286 4.258,20.331C4.794,20.375 5.454,20.375 6.263,20.375H17.737C18.546,20.375 19.206,20.375 19.742,20.331C20.296,20.286 20.794,20.19 21.259,19.953C21.988,19.581 22.581,18.988 22.953,18.259C23.19,17.794 23.286,17.296 23.331,16.742C23.375,16.206 23.375,15.546 23.375,14.737V9.263C23.375,8.454 23.375,7.794 23.331,7.258C23.286,6.704 23.19,6.206 22.953,5.741C22.581,5.012 21.988,4.419 21.259,4.047C20.794,3.811 20.296,3.714 19.742,3.669C19.206,3.625 18.546,3.625 17.737,3.625ZM3.535,5.607C3.712,5.516 3.955,5.449 4.401,5.413C4.857,5.376 5.445,5.375 6.3,5.375H17.7C18.555,5.375 19.143,5.376 19.599,5.413C20.045,5.449 20.288,5.516 20.465,5.607C20.865,5.81 21.19,6.135 21.393,6.535C21.484,6.712 21.551,6.955 21.587,7.401C21.624,7.857 21.625,8.445 21.625,9.3V14.7C21.625,15.554 21.624,16.143 21.587,16.599C21.551,17.045 21.484,17.288 21.393,17.465C21.19,17.865 20.865,18.19 20.465,18.393C20.288,18.484 20.045,18.551 19.599,18.587C19.143,18.624 18.555,18.625 17.7,18.625H6.3C5.445,18.625 4.857,18.624 4.401,18.587C3.955,18.551 3.712,18.484 3.535,18.393C3.135,18.19 2.81,17.865 2.607,17.465C2.516,17.288 2.449,17.045 2.413,16.599C2.376,16.143 2.375,15.554 2.375,14.7V9.3C2.375,8.445 2.376,7.857 2.413,7.401C2.449,6.955 2.516,6.712 2.607,6.535C2.81,6.135 3.135,5.81 3.535,5.607Z" />
|
||||
</vector>
|
||||
BIN
core-ui/src/main/res/drawable/qrcode_logo.png
Normal file
BIN
core-ui/src/main/res/drawable/qrcode_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
14
core-ui/src/main/res/drawable/symbol_camera_24.xml
Normal file
14
core-ui/src/main/res/drawable/symbol_camera_24.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12,6.375a5.875,5.875 0,1 0,0 11.75,5.875 5.875,0 0,0 0,-11.75ZM7.875,12.25a4.125,4.125 0,1 1,8.25 0,4.125 4.125,0 0,1 -8.25,0Z"
|
||||
android:fillColor="#000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M10.13,1.625c-0.806,0 -1.575,0.338 -2.12,0.932L6.803,3.875H5.5A4.375,4.375 0,0 0,1.125 8.25v9.25A4.375,4.375 0,0 0,5.5 21.875h13a4.375,4.375 0,0 0,4.375 -4.375V8.25A4.375,4.375 0,0 0,18.5 3.875h-1.303L15.99,2.557a2.875,2.875 0,0 0,-2.12 -0.932h-3.74ZM9.3,3.74c0.214,-0.233 0.514,-0.365 0.83,-0.365h3.74c0.316,0 0.616,0.132 0.83,0.365l1.468,1.601c0.165,0.181 0.4,0.284 0.645,0.284H18.5a2.625,2.625 0,0 1,2.625 2.625v9.25a2.625,2.625 0,0 1,-2.625 2.625h-13A2.625,2.625 0,0 1,2.875 17.5V8.25A2.625,2.625 0,0 1,5.5 5.625h1.688a0.875,0.875 0,0 0,0.645 -0.284L9.3,3.74Z"
|
||||
android:fillColor="#000"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
9
core-ui/src/main/res/drawable/symbol_phone_24.xml
Normal file
9
core-ui/src/main/res/drawable/symbol_phone_24.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M4.25 2.73c1.25-1.25 3.3-1.1 4.37 0.3l2.17 2.85c0.88 1.15 0.77 2.78-0.26 3.8l-1.05 1.06c0.03 0.09 0.09 0.22 0.18 0.39 0.28 0.5 0.77 1.14 1.42 1.79 0.65 0.65 1.3 1.14 1.8 1.42 0.16 0.09 0.29 0.15 0.38 0.18l1.05-1.05c1.03-1.03 2.66-1.14 3.81-0.26l2.86 2.17c1.4 1.06 1.54 3.12 0.3 4.37l-0.44 0.43c-1.57 1.57-3.91 2.43-6.16 1.66-2.82-0.97-5.46-2.57-7.7-4.81-2.25-2.25-3.85-4.9-4.82-7.7-0.77-2.26 0.09-4.6 1.66-6.17l0.43-0.43Zm2.98 1.35C6.8 3.52 5.99 3.47 5.49 3.96L5.06 4.4C3.84 5.62 3.3 7.28 3.82 8.76c0.88 2.56 2.34 4.98 4.4 7.03 2.04 2.05 4.46 3.51 7.02 4.4 1.48 0.5 3.14-0.03 4.36-1.25l0.44-0.43c0.5-0.5 0.44-1.31-0.12-1.74l-2.85-2.17c-0.46-0.34-1.11-0.3-1.52 0.1l-1.24 1.25c-0.41 0.41-0.96 0.38-1.26 0.32-0.34-0.06-0.7-0.22-1.03-0.4-0.67-0.38-1.44-0.98-2.18-1.71-0.73-0.74-1.33-1.51-1.7-2.18-0.2-0.33-0.35-0.69-0.41-1.03-0.06-0.3-0.1-0.85 0.32-1.26l1.24-1.24C9.7 8.04 9.74 7.39 9.4 6.93L7.24 4.08Z"/>
|
||||
</vector>
|
||||
30
core-ui/src/main/res/drawable/symbol_qrcode_24.xml
Normal file
30
core-ui/src/main/res/drawable/symbol_qrcode_24.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M5.25,6A0.75,0.75 0,0 1,6 5.25h1a0.75,0.75 0,0 1,0.75 0.75v1a0.75,0.75 0,0 1,-0.75 0.75H6A0.75,0.75 0,0 1,5.25 7V6Z"
|
||||
android:fillColor="#000"/>
|
||||
<path
|
||||
android:pathData="M5.117,1.875h2.766c0.392,0 0.737,0 1.023,0.023 0.305,0.025 0.618,0.08 0.922,0.236 0.447,0.228 0.81,0.59 1.038,1.038 0.155,0.304 0.21,0.617 0.236,0.922 0.023,0.286 0.023,0.631 0.023,1.023v2.766c0,0.392 0,0.737 -0.023,1.023 -0.025,0.305 -0.08,0.618 -0.236,0.922 -0.228,0.447 -0.59,0.81 -1.038,1.038 -0.304,0.155 -0.617,0.21 -0.922,0.236 -0.286,0.023 -0.631,0.023 -1.023,0.023L5.117,11.125c-0.392,0 -0.737,0 -1.023,-0.023 -0.305,-0.025 -0.618,-0.08 -0.922,-0.236a2.375,2.375 0,0 1,-1.038 -1.038c-0.155,-0.304 -0.21,-0.617 -0.236,-0.922a13.474,13.474 0,0 1,-0.023 -1.023L1.875,5.117c0,-0.392 0,-0.737 0.023,-1.023 0.025,-0.305 0.08,-0.618 0.236,-0.922 0.228,-0.447 0.59,-0.81 1.038,-1.038 0.304,-0.155 0.617,-0.21 0.922,-0.236 0.286,-0.023 0.631,-0.023 1.023,-0.023ZM4.237,3.643c-0.197,0.016 -0.254,0.042 -0.27,0.05a0.625,0.625 0,0 0,-0.274 0.273c-0.008,0.017 -0.034,0.074 -0.05,0.27 -0.017,0.206 -0.018,0.48 -0.018,0.914v2.7c0,0.434 0,0.708 0.018,0.914 0.016,0.196 0.042,0.253 0.05,0.27 0.06,0.117 0.156,0.213 0.273,0.273 0.017,0.008 0.074,0.034 0.27,0.05 0.206,0.017 0.48,0.018 0.914,0.018h2.7c0.434,0 0.708,0 0.914,-0.018 0.196,-0.016 0.253,-0.042 0.27,-0.05a0.625,0.625 0,0 0,0.273 -0.273c0.008,-0.017 0.034,-0.074 0.05,-0.27 0.017,-0.206 0.018,-0.48 0.018,-0.914v-2.7c0,-0.434 0,-0.708 -0.018,-0.914 -0.016,-0.196 -0.042,-0.253 -0.05,-0.27a0.625,0.625 0,0 0,-0.273 -0.273c-0.017,-0.008 -0.074,-0.034 -0.27,-0.05a12.67,12.67 0,0 0,-0.914 -0.018h-2.7c-0.434,0 -0.708,0 -0.914,0.018Z"
|
||||
android:fillColor="#000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M6,16.25a0.75,0.75 0,0 0,-0.75 0.75v1c0,0.414 0.336,0.75 0.75,0.75h1a0.75,0.75 0,0 0,0.75 -0.75v-1a0.75,0.75 0,0 0,-0.75 -0.75H6Z"
|
||||
android:fillColor="#000"/>
|
||||
<path
|
||||
android:pathData="M5.117,12.875h2.766c0.392,0 0.737,0 1.023,0.023 0.305,0.025 0.618,0.08 0.922,0.236 0.447,0.228 0.81,0.59 1.038,1.038 0.155,0.304 0.21,0.617 0.236,0.922 0.023,0.286 0.023,0.631 0.023,1.023v2.766c0,0.392 0,0.737 -0.023,1.024 -0.025,0.304 -0.08,0.617 -0.236,0.921 -0.228,0.447 -0.59,0.81 -1.038,1.038 -0.304,0.155 -0.617,0.21 -0.922,0.236 -0.286,0.023 -0.631,0.023 -1.023,0.023L5.117,22.125c-0.392,0 -0.737,0 -1.023,-0.023 -0.305,-0.025 -0.618,-0.08 -0.922,-0.236a2.375,2.375 0,0 1,-1.038 -1.038c-0.155,-0.304 -0.21,-0.617 -0.236,-0.921a13.476,13.476 0,0 1,-0.023 -1.024v-2.766c0,-0.392 0,-0.737 0.023,-1.023 0.025,-0.305 0.08,-0.618 0.236,-0.922 0.228,-0.447 0.59,-0.81 1.038,-1.038 0.304,-0.155 0.617,-0.21 0.922,-0.236 0.286,-0.023 0.631,-0.023 1.023,-0.023ZM4.237,14.643c-0.197,0.015 -0.254,0.042 -0.27,0.05a0.625,0.625 0,0 0,-0.274 0.273c-0.008,0.017 -0.034,0.074 -0.05,0.27 -0.017,0.206 -0.018,0.48 -0.018,0.914v2.7c0,0.434 0,0.708 0.018,0.914 0.016,0.196 0.042,0.253 0.05,0.27 0.06,0.117 0.156,0.213 0.273,0.273 0.017,0.008 0.074,0.035 0.27,0.05 0.206,0.017 0.48,0.018 0.914,0.018h2.7c0.434,0 0.708,0 0.914,-0.017 0.196,-0.017 0.253,-0.043 0.27,-0.051a0.625,0.625 0,0 0,0.273 -0.273c0.008,-0.017 0.034,-0.074 0.05,-0.27 0.017,-0.206 0.018,-0.48 0.018,-0.914v-2.7c0,-0.434 0,-0.708 -0.018,-0.914 -0.016,-0.196 -0.042,-0.253 -0.05,-0.27a0.625,0.625 0,0 0,-0.273 -0.273c-0.017,-0.008 -0.074,-0.034 -0.27,-0.05 -0.206,-0.017 -0.48,-0.018 -0.914,-0.018h-2.7c-0.434,0 -0.708,0 -0.914,0.018Z"
|
||||
android:fillColor="#000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M17,5.25a0.75,0.75 0,0 0,-0.75 0.75v1c0,0.414 0.336,0.75 0.75,0.75h1a0.75,0.75 0,0 0,0.75 -0.75V6a0.75,0.75 0,0 0,-0.75 -0.75h-1Z"
|
||||
android:fillColor="#000"/>
|
||||
<path
|
||||
android:pathData="M16.117,1.875c-0.392,0 -0.737,0 -1.023,0.023 -0.305,0.025 -0.618,0.08 -0.922,0.236 -0.447,0.228 -0.81,0.59 -1.038,1.038 -0.155,0.304 -0.21,0.617 -0.236,0.922 -0.023,0.286 -0.023,0.631 -0.023,1.023v2.766c0,0.392 0,0.737 0.023,1.023 0.025,0.305 0.08,0.618 0.236,0.922 0.228,0.447 0.59,0.81 1.038,1.038 0.304,0.155 0.617,0.21 0.922,0.236 0.286,0.023 0.631,0.023 1.023,0.023h2.766c0.392,0 0.737,0 1.024,-0.023 0.304,-0.025 0.617,-0.08 0.921,-0.236 0.447,-0.228 0.81,-0.59 1.038,-1.038 0.155,-0.304 0.21,-0.617 0.236,-0.922 0.023,-0.286 0.023,-0.631 0.023,-1.023L22.125,5.117c0,-0.392 0,-0.737 -0.023,-1.023 -0.025,-0.305 -0.08,-0.618 -0.236,-0.922a2.375,2.375 0,0 0,-1.038 -1.038c-0.304,-0.155 -0.617,-0.21 -0.921,-0.236a13.476,13.476 0,0 0,-1.024 -0.023h-2.766ZM14.967,3.693c0.016,-0.008 0.073,-0.034 0.27,-0.05 0.205,-0.017 0.479,-0.018 0.913,-0.018h2.7c0.434,0 0.708,0 0.914,0.018 0.196,0.016 0.253,0.042 0.27,0.05 0.117,0.06 0.213,0.156 0.273,0.273 0.008,0.017 0.035,0.074 0.05,0.27 0.017,0.206 0.018,0.48 0.018,0.914v2.7c0,0.434 0,0.708 -0.017,0.914 -0.017,0.196 -0.043,0.253 -0.051,0.27a0.625,0.625 0,0 1,-0.273 0.273c-0.017,0.008 -0.074,0.034 -0.27,0.05 -0.206,0.017 -0.48,0.018 -0.914,0.018h-2.7c-0.434,0 -0.708,0 -0.914,-0.018 -0.196,-0.016 -0.253,-0.042 -0.27,-0.05a0.625,0.625 0,0 1,-0.273 -0.273c-0.008,-0.017 -0.034,-0.074 -0.05,-0.27 -0.017,-0.206 -0.018,-0.48 -0.018,-0.914v-2.7c0,-0.434 0,-0.708 0.018,-0.914 0.015,-0.196 0.042,-0.253 0.05,-0.27a0.625,0.625 0,0 1,0.273 -0.273Z"
|
||||
android:fillColor="#000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M14.25,13.5a0.75,0.75 0,0 0,-0.75 0.75v1c0,0.414 0.336,0.75 0.75,0.75h1a0.75,0.75 0,0 0,0.75 -0.75v-1a0.75,0.75 0,0 0,-0.75 -0.75h-1ZM16.25,17a0.75,0.75 0,0 1,0.75 -0.75h1a0.75,0.75 0,0 1,0.75 0.75v1a0.75,0.75 0,0 1,-0.75 0.75h-1a0.75,0.75 0,0 1,-0.75 -0.75v-1ZM19,19.75a0.75,0.75 0,0 1,0.75 -0.75h1a0.75,0.75 0,0 1,0.75 0.75v1a0.75,0.75 0,0 1,-0.75 0.75h-1a0.75,0.75 0,0 1,-0.75 -0.75v-1ZM19.75,13.5a0.75,0.75 0,0 0,-0.75 0.75v1c0,0.414 0.336,0.75 0.75,0.75h1a0.75,0.75 0,0 0,0.75 -0.75v-1a0.75,0.75 0,0 0,-0.75 -0.75h-1ZM13.5,19.75a0.75,0.75 0,0 1,0.75 -0.75h1a0.75,0.75 0,0 1,0.75 0.75v1a0.75,0.75 0,0 1,-0.75 0.75h-1a0.75,0.75 0,0 1,-0.75 -0.75v-1Z"
|
||||
android:fillColor="#000"/>
|
||||
</vector>
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <reified T> Json.decodeFromStringOrNull(string: String): T? {
|
||||
return runCatching { decodeFromString<T>(string) }.getOrNull()
|
||||
}
|
||||
@@ -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<ByteArray> {
|
||||
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<KEMPublicKey> {
|
||||
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<ECPublicKey> {
|
||||
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()))
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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<String, String> headers = locale != null ? Collections.singletonMap("Accept-Language", locale.getLanguage() + "-" + locale.getCountry()) : NO_HEADERS;
|
||||
Map<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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();
|
||||
|
||||
60
registration/app/build.gradle.kts
Normal file
60
registration/app/build.gradle.kts
Normal file
@@ -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)
|
||||
}
|
||||
21
registration/app/src/main/AndroidManifest.xml
Normal file
21
registration/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:name=".RegistrationApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@android:drawable/sym_def_app_icon"
|
||||
android:label="Registration Sample"
|
||||
android:theme="@android:style/Theme.Material.NoActionBar"
|
||||
android:supportsRtl="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<SessionMetadata, CreateSessionError> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
pushServiceSocket.createVerificationSessionV2(e164, fcmToken, mcc, mnc).use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val session = json.decodeFromString<SessionMetadata>(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<SessionMetadata, GetSessionStatusError> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
pushServiceSocket.getSessionStatusV2(sessionId).use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val session = json.decodeFromString<SessionMetadata>(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<SessionMetadata, UpdateSessionError> = 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<SessionMetadata>(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<SessionMetadata>(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<SessionMetadata, RequestVerificationCodeError> = 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<SessionMetadata>(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<SessionMetadata>(response.body.string())
|
||||
RegistrationNetworkResult.Failure(RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified(session))
|
||||
}
|
||||
418 -> {
|
||||
val session = json.decodeFromString<SessionMetadata>(response.body.string())
|
||||
RegistrationNetworkResult.Failure(RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport(session))
|
||||
}
|
||||
429 -> {
|
||||
val session = json.decodeFromString<SessionMetadata>(response.body.string())
|
||||
RegistrationNetworkResult.Failure(RequestVerificationCodeError.RateLimited(response.retryAfter(), session))
|
||||
}
|
||||
440 -> {
|
||||
val errorBody = json.decodeFromString<ThirdPartyServiceErrorResponse>(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<SessionMetadata, SubmitVerificationCodeError> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
pushServiceSocket.submitVerificationCodeV2(sessionId, verificationCode).use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val session = json.decodeFromString<SessionMetadata>(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<SessionMetadata>(response.body.string())
|
||||
RegistrationNetworkResult.Failure(SubmitVerificationCodeError.SessionAlreadyVerifiedOrNoCodeRequested(session))
|
||||
}
|
||||
429 -> {
|
||||
val session = json.decodeFromString<SessionMetadata>(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<RegisterAccountResponse, RegisterAccountError> = 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<RegisterAccountResponse>(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<RegistrationLockResponse>(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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
registration/app/src/main/res/raw/whisper.store
Normal file
BIN
registration/app/src/main/res/raw/whisper.store
Normal file
Binary file not shown.
71
registration/lib/build.gradle.kts
Normal file
71
registration/lib/build.gradle.kts
Normal file
@@ -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)
|
||||
}
|
||||
19
registration/lib/src/main/AndroidManifest.xml
Normal file
19
registration/lib/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".RegistrationActivity"
|
||||
android:exported="false"
|
||||
android:theme="@android:style/Theme.Material.NoActionBar" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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<SessionMetadata, CreateSessionError>
|
||||
|
||||
/**
|
||||
* Retrieve current status of a registration session.
|
||||
*
|
||||
* `GET /v1/verification/session/{session-id}`
|
||||
*/
|
||||
suspend fun getSession(sessionId: String): RegistrationNetworkResult<SessionMetadata, GetSessionStatusError>
|
||||
|
||||
/**
|
||||
* Update the session with new information.
|
||||
*
|
||||
* `PATCH /v1/verification/session/{session-id}`
|
||||
*/
|
||||
suspend fun updateSession(sessionId: String?, pushChallengeToken: String?, captchaToken: String?): RegistrationNetworkResult<SessionMetadata, UpdateSessionError>
|
||||
|
||||
/**
|
||||
* 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<SessionMetadata, RequestVerificationCodeError>
|
||||
|
||||
/**
|
||||
* 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<SessionMetadata, SubmitVerificationCodeError>
|
||||
|
||||
/**
|
||||
* 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<RegisterAccountResponse, RegisterAccountError>
|
||||
|
||||
/**
|
||||
* 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<String>)
|
||||
//
|
||||
// /**
|
||||
// * Validates the provided SVR3 auth credentials, returning information on their usability.
|
||||
// *
|
||||
// * `POST /v3/backup/auth/check`
|
||||
// */
|
||||
// suspend fun validateSvr3AuthCredential(e164: String, usernamePasswords: List<String>)
|
||||
//
|
||||
// /**
|
||||
// * 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<out SuccessModel, out FailureModel> {
|
||||
data class Success<T>(val data: T) : RegistrationNetworkResult<T, Nothing>
|
||||
data class Failure<T>(val error: T) : RegistrationNetworkResult<Nothing, T>
|
||||
data class NetworkError(val exception: IOException) : RegistrationNetworkResult<Nothing, Nothing>
|
||||
data class ApplicationError(val exception: Throwable) : RegistrationNetworkResult<Nothing, Nothing>
|
||||
}
|
||||
|
||||
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<String>,
|
||||
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<Badge>,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<Unit, Boolean>() {
|
||||
override fun createIntent(context: Context, input: Unit): Intent {
|
||||
return createIntent(context)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
||||
return resultCode == RESULT_OK
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<RegistrationRoute> = listOf(RegistrationRoute.Welcome),
|
||||
val sessionMetadata: NetworkController.SessionMetadata? = null,
|
||||
val sessionE164: String? = null
|
||||
) : Parcelable
|
||||
@@ -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<NavKey>()
|
||||
)
|
||||
|
||||
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<NavKey>.registrationEntries(
|
||||
registrationRepository: RegistrationRepository,
|
||||
registrationViewModel: RegistrationViewModel,
|
||||
permissionsState: MultiplePermissionsState,
|
||||
navigator: RegistrationNavigator,
|
||||
onRegistrationComplete: () -> Unit
|
||||
) {
|
||||
// --- Welcome Screen
|
||||
entry<RegistrationRoute.Welcome> {
|
||||
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<RegistrationRoute.Permissions> { key ->
|
||||
PermissionsScreen(
|
||||
permissionsState = permissionsState,
|
||||
onProceed = {
|
||||
if (key.forRestore) {
|
||||
navigator.navigate(RegistrationRoute.RestoreViaQr)
|
||||
} else {
|
||||
navigator.navigate(RegistrationRoute.PhoneNumberEntry)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// -- Phone Number Entry Screen
|
||||
entry<RegistrationRoute.PhoneNumberEntry> {
|
||||
val viewModel: PhoneNumberEntryViewModel = viewModel(
|
||||
factory = PhoneNumberEntryViewModel.Factory(
|
||||
repository = registrationRepository,
|
||||
parentState = registrationViewModel.state,
|
||||
parentEventEmitter = registrationViewModel::onEvent
|
||||
)
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
ResultEffect<String?>(registrationViewModel.resultBus, CAPTCHA_RESULT) { captchaToken ->
|
||||
if (captchaToken != null) {
|
||||
viewModel.onEvent(PhoneNumberEntryScreenEvents.CaptchaCompleted(captchaToken))
|
||||
}
|
||||
}
|
||||
|
||||
PhoneNumberScreen(
|
||||
state = state,
|
||||
onEvent = { viewModel.onEvent(it) }
|
||||
)
|
||||
}
|
||||
|
||||
// -- Country Code Picker
|
||||
entry<RegistrationRoute.CountryCodePicker> {
|
||||
// We'll also want this to be some sort of launch-for-result flow as well
|
||||
TODO()
|
||||
}
|
||||
|
||||
// -- Captcha Screen
|
||||
entry<RegistrationRoute.Captcha> {
|
||||
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<RegistrationRoute.VerificationCodeEntry> {
|
||||
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<RegistrationRoute.Profile> {
|
||||
// TODO: Implement ProfileScreen
|
||||
}
|
||||
|
||||
entry<RegistrationRoute.PinSetup> {
|
||||
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<RegistrationRoute.Restore> {
|
||||
// TODO: Implement RestoreScreen
|
||||
}
|
||||
|
||||
entry<RegistrationRoute.RestoreViaQr> {
|
||||
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<RegistrationRoute.Transfer> {
|
||||
// TODO: Implement TransferScreen
|
||||
}
|
||||
|
||||
entry<RegistrationRoute.FullyComplete> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<SessionMetadata, CreateSessionError> = 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<SessionMetadata, RequestVerificationCodeError> = 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<SessionMetadata, UpdateSessionError> = withContext(Dispatchers.IO) {
|
||||
networkController.updateSession(
|
||||
sessionId = sessionId,
|
||||
pushChallengeToken = null,
|
||||
captchaToken = captchaToken
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun submitVerificationCode(
|
||||
sessionId: String,
|
||||
verificationCode: String
|
||||
): RegistrationNetworkResult<SessionMetadata, NetworkController.SubmitVerificationCodeError> = 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<RegisterAccountResponse, RegisterAccountError> = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<RegistrationFlowState> = savedStateHandle.getMutableStateFlow("registration_state", initialValue = RegistrationFlowState())
|
||||
val state: StateFlow<RegistrationFlowState> = _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<String> {
|
||||
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 <T : ViewModel> create(modelClass: KClass<T>, extras: CreationExtras): T {
|
||||
return RegistrationViewModel(repository, extras.createSavedStateHandle()) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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<String>,
|
||||
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<String>): List<String> {
|
||||
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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<RegistrationFlowState>,
|
||||
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<NetworkController.SessionMetadata> -> {
|
||||
response.data
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.Failure<NetworkController.CreateSessionError> -> {
|
||||
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<NetworkController.SessionMetadata> -> {
|
||||
verificationCodeResponse.data
|
||||
}
|
||||
is NetworkController.RegistrationNetworkResult.Failure<NetworkController.RequestVerificationCodeError> -> {
|
||||
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<RegistrationFlowState>,
|
||||
val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return PhoneNumberEntryViewModel(repository, parentState, parentEventEmitter) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<PermissionState> = emptyList(),
|
||||
override val revokedPermissions: List<PermissionState> = emptyList(),
|
||||
override val shouldShowRationale: Boolean = false
|
||||
) : MultiplePermissionsState {
|
||||
override fun launchMultiplePermissionRequest() = Unit
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<RegistrationFlowState>,
|
||||
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<RegistrationFlowState>,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return VerificationCodeViewModel(repository, parentState, parentEventEmitter) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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<RegistrationRepository>(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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user