Add initial registration v5 prototype.

This commit is contained in:
Greyson Parrelli
2025-11-25 16:55:29 -05:00
committed by jeffrey-signal
parent 1a5163fc47
commit 5ea5279fbb
105 changed files with 8146 additions and 44 deletions

View File

@@ -109,7 +109,7 @@ fun DevicePinAuthEducationSheet(
@DayNightPreviews
@Composable
fun DevicePinAuthEducationSheetPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
DevicePinAuthEducationSheet(
title = "To continue, confirm it's you",
onClick = {}

View File

@@ -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) { }

View File

@@ -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",

View File

@@ -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 = {}

View File

@@ -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(

View File

@@ -74,7 +74,7 @@ private fun NoManualBackupSheetContent(
@DayNightPreviews
@Composable
private fun NoManualBackupSheetContentPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
NoManualBackupSheetContent(
durationSinceLastBackup = 30.days
)

View File

@@ -111,7 +111,7 @@ private fun NoRemoteStorageSpaceAvailableBottomSheetContent(
@DayNightPreviews
@Composable
private fun NoRemoteStorageSpaceAvailableBottomSheetContentPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
NoRemoteStorageSpaceAvailableBottomSheetContent({}, {}, {})
}
}

View File

@@ -521,7 +521,7 @@ private fun SaveKeyConfirmationDialogPreview() {
@DayNightPreviews
@Composable
private fun CreateNewBackupKeySheetContentPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
Column {
CreateNewBackupKeySheetContent()
}

View File

@@ -205,7 +205,7 @@ private fun MessageBackupsKeyRecordScreenPreview() {
@DayNightPreviews
@Composable
private fun BottomSheetContentPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
BottomSheetContent({}, {})
}
}

View File

@@ -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

View File

@@ -328,7 +328,7 @@ private fun CreateCallLinkBottomSheetContent(
@DayNightPreviews
@Composable
private fun CreateCallLinkBottomSheetContentPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
CreateCallLinkBottomSheetContent(
callLink = CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,

View File

@@ -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,

View File

@@ -105,7 +105,7 @@ private fun Sheet(onDismiss: () -> Unit = {}) {
@DayNightPreviews
@Composable
private fun ConnectivityWarningSheetPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
Sheet()
}
}

View File

@@ -127,7 +127,7 @@ private fun DeviceSpecificSheet(onContinue: () -> Unit = {}, onDismiss: () -> Un
@DayNightPreviews
@Composable
private fun DeviceSpecificSheetPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
DeviceSpecificSheet()
}
}

View File

@@ -167,7 +167,7 @@ private fun SubscriptionNotFoundReason(text: String) {
@DayNightPreviews
@Composable
private fun SubscriptionNotFoundContentPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
Column {
SubscriptionNotFoundContent()
}

View File

@@ -109,7 +109,7 @@ fun EducationRow(text: String, painter: Painter) {
@DayNightPreviews
@Composable
fun ChatFoldersEducationSheetPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
FolderEducationSheet(onClick = {})
}
}

View File

@@ -135,7 +135,7 @@ class PendingParticipantsBottomSheet : ComposeBottomSheetDialogFragment() {
@NightPreview
@Composable
private fun PendingParticipantsSheetPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
PendingParticipantsSheet(
pendingParticipants = listOf(
PendingParticipantCollection.State.PENDING,

View File

@@ -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)

View File

@@ -106,7 +106,7 @@ class CallLinkIncomingRequestSheet : ComposeBottomSheetDialogFragment() {
@NightPreview
@Composable
private fun CallLinkIncomingRequestSheetContentPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
CallLinkIncomingRequestSheetContent(
state = CallLinkIncomingRequestState(
name = "Miles Morales",

View File

@@ -266,7 +266,7 @@ private fun LegacyAudioPickerContent(
@NightPreview
@Composable
private fun CallAudioPickerSheetContentPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
Column {
LegacyAudioPickerContent(
toggleButtonOutputState = ToggleButtonOutputState().apply {

View File

@@ -118,7 +118,7 @@ private fun MediaNoLongerAvailableBottomSheetContent(
@DayNightPreviews
@Composable
private fun MediaNoLongerAvailableBottomSheetContentPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
MediaNoLongerAvailableBottomSheetContent()
}
}

View File

@@ -159,7 +159,7 @@ fun InfoRow(text: String) {
@DayNightPreviews
@Composable
private fun ProfileNameSheetPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
ProfileNameSheet()
}
}

View File

@@ -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),

View File

@@ -84,7 +84,7 @@ fun FinishedSheet(onClick: () -> Unit) {
@DayNightPreviews
@Composable
fun FinishedSheetSheetPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
FinishedSheet(onClick = {})
}
}

View File

@@ -76,7 +76,7 @@ fun EducationSheet(onClick: () -> Unit) {
@DayNightPreviews
@Composable
fun EducationSheetPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
EducationSheet(onClick = {})
}
}

View File

@@ -116,7 +116,7 @@ private fun LinkedDeviceInformationRow(
@DayNightPreviews
@Composable
fun LearnMorePreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
LearnMoreSheet()
}
}

View File

@@ -128,7 +128,7 @@ private fun SheetOption(
@DayNightPreviews
@Composable
fun SyncSheetSheetSheetPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
SyncSheet(onLink = {})
}
}

View File

@@ -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,

View File

@@ -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
)

View File

@@ -104,7 +104,7 @@ private fun Sheet(
@Composable
@DayNightPreviews
private fun SheetPreview() {
Previews.BottomSheetPreview {
Previews.BottomSheetContentPreview {
Sheet()
}
}

View File

@@ -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)
}

View File

@@ -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
*/

View File

@@ -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()
}
}
}
}
}

View 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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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
)
}
}
}
}
}

View File

@@ -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

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View 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>

View 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>

View 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>

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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()))
}
}

View File

@@ -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"]

View File

@@ -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

View File

@@ -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)

View File

@@ -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();

View 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)
}

View 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>

View File

@@ -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")
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}

Binary file not shown.

View 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)
}

View 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>

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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
)
}
}

View File

@@ -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
}
}
}

View File

@@ -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
)

View File

@@ -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
)
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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()
}

View File

@@ -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
)

View File

@@ -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 = {}
)
}
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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()
}

View File

@@ -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
)

View File

@@ -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 = {}
)
}
}

View File

@@ -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()
}

View File

@@ -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
)

View File

@@ -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 = {}
)
}
}

View File

@@ -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()
}

View File

@@ -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
)

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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()
}

View File

@@ -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"
}

View File

@@ -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