Re-organize gradle modules.

This commit is contained in:
Greyson Parrelli
2025-12-31 11:56:13 -05:00
committed by jeffrey-signal
parent f4863efb2e
commit e162eb27c7
1444 changed files with 111 additions and 144 deletions

View File

@@ -0,0 +1,118 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
id("signal-sample-app")
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlinx.serialization)
}
val keystoreProperties: Properties? = loadKeystoreProperties("keystore.debug.properties")
android {
namespace = "org.signal.registration.sample"
defaultConfig {
// IMPORTANT: We use the same package name as the signal staging app so that FCM works.
applicationId = "org.thoughtcrime.securesms.staging"
versionCode = 1
versionName = "1.0"
minSdk = 26
targetSdk = 34
}
buildFeatures {
compose = true
}
keystoreProperties?.let { properties ->
signingConfigs.getByName("debug").apply {
storeFile = file("${project.rootDir}/${properties.getProperty("storeFile")}")
storePassword = properties.getProperty("storePassword")
keyAlias = properties.getProperty("keyAlias")
keyPassword = properties.getProperty("keyPassword")
}
}
buildTypes {
getByName("debug") {
if (keystoreProperties != null) {
signingConfig = signingConfigs["debug"]
}
}
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}
dependencies {
lintChecks(project(":lintchecks"))
// Registration library
implementation(project(":feature:registration"))
// Core dependencies
implementation(project(":core:ui"))
implementation(project(":core:util"))
implementation(project(":core:models"))
implementation(project(":lib: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)
implementation(libs.androidx.sqlite)
implementation(libs.androidx.sqlite.framework)
// Lifecycle
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Kotlinx Serialization
implementation(libs.kotlinx.serialization.json)
// Navigation 3
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
// Permissions
implementation(libs.accompanist.permissions)
// 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)
// Firebase & Play Services
implementation(libs.firebase.messaging) {
exclude(group = "com.google.firebase", module = "firebase-core")
exclude(group = "com.google.firebase", module = "firebase-analytics")
exclude(group = "com.google.firebase", module = "firebase-measurement-connector")
}
implementation(libs.google.play.services.base)
implementation(libs.kotlinx.coroutines.play.services)
}
fun loadKeystoreProperties(filename: String): Properties? {
val keystorePropertiesFile = file("${project.rootDir}/$filename")
return if (keystorePropertiesFile.exists()) {
val properties = Properties()
properties.load(FileInputStream(keystorePropertiesFile))
properties
} else {
null
}
}

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".RegistrationApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="Registration Sample"
android:theme="@android:style/Theme.Material.NoActionBar"
android:supportsRtl="true">
<!-- Disable Firebase Analytics -->
<meta-data android:name="firebase_analytics_collection_deactivated" android:value="true" />
<meta-data android:name="firebase_messaging_auto_init_enabled" android:value="false" />
<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>
<!-- FCM Service for receiving push challenges -->
<service
android:name=".fcm.FcmReceiveService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -0,0 +1,272 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
@file:OptIn(ExperimentalPermissionsApi::class)
package org.signal.registration.sample
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.serialization.Serializable
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.registration.RegistrationDependencies
import org.signal.registration.RegistrationNavHost
import org.signal.registration.RegistrationRepository
import org.signal.registration.sample.debug.NetworkDebugOverlay
import org.signal.registration.sample.screens.RegistrationCompleteScreen
import org.signal.registration.sample.screens.main.MainScreen
import org.signal.registration.sample.screens.main.MainScreenViewModel
import org.signal.registration.sample.screens.pinsettings.PinSettingsScreen
import org.signal.registration.sample.screens.pinsettings.PinSettingsViewModel
private const val ANIMATION_DURATION = 300
/**
* Navigation routes for the sample app.
*/
sealed interface SampleRoute : NavKey {
@Serializable
data object Main : SampleRoute
@Serializable
data object Registration : SampleRoute
@Serializable
data object RegistrationComplete : SampleRoute
@Serializable
data object PinSettings : SampleRoute
}
/**
* Sample app activity that launches the registration flow for testing.
*/
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SignalTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AppScreen(RegistrationDependencies.get())
}
}
}
}
}
@Composable
fun AppScreen(registrationDependencies: RegistrationDependencies) {
val backStack = rememberNavBackStack(SampleRoute.Main)
Box(
modifier = Modifier.fillMaxSize()
) {
SampleNavHost(
backStack = backStack,
registrationDependencies = registrationDependencies,
onStartOver = {
backStack.clear()
backStack.add(SampleRoute.Main)
}
)
// Debug overlay for forcing network responses
NetworkDebugOverlay(
modifier = Modifier.fillMaxSize()
)
}
}
@Composable
private fun SampleNavHost(
onStartOver: () -> Unit,
registrationDependencies: RegistrationDependencies,
backStack: NavBackStack<NavKey>,
modifier: Modifier = Modifier
) {
val registrationRepository = remember {
RegistrationRepository(
networkController = registrationDependencies.networkController,
storageController = registrationDependencies.storageController
)
}
val entryProvider: (NavKey) -> NavEntry<NavKey> = entryProvider {
entry<SampleRoute.Main> {
val viewModel: MainScreenViewModel = viewModel(
factory = MainScreenViewModel.Factory(
storageController = registrationDependencies.storageController,
onLaunchRegistration = { backStack.add(SampleRoute.Registration) },
onOpenPinSettings = { backStack.add(SampleRoute.PinSettings) }
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
LifecycleResumeEffect(Unit) {
viewModel.refreshData()
onPauseOrDispose { }
}
MainScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
)
}
entry<SampleRoute.Registration> {
RegistrationNavHost(
registrationRepository,
modifier = Modifier.fillMaxSize(),
onRegistrationComplete = {
backStack.add(SampleRoute.RegistrationComplete)
}
)
}
entry<SampleRoute.RegistrationComplete> {
RegistrationCompleteScreen(onStartOver = onStartOver)
}
entry<SampleRoute.PinSettings>(
metadata = BottomSheetTransitionSpec
) {
val viewModel: PinSettingsViewModel = viewModel(
factory = PinSettingsViewModel.Factory(
networkController = registrationDependencies.networkController,
onBack = { backStack.removeLastOrNull() }
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
PinSettingsScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
)
}
}
val decorators = listOf(
rememberSaveableStateHolderNavEntryDecorator<NavKey>()
)
val entries = rememberDecoratedNavEntries(
backStack = backStack,
entryDecorators = decorators,
entryProvider = entryProvider
)
NavDisplay(
entries = entries,
onBack = { backStack.removeLastOrNull() },
modifier = modifier,
transitionSpec = {
// Default: slide in from right, previous screen shrinks back
(
slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(ANIMATION_DURATION)
) + fadeIn(animationSpec = tween(ANIMATION_DURATION))
) togetherWith (
fadeOut(animationSpec = tween(ANIMATION_DURATION)) +
scaleOut(
targetScale = 0.9f,
animationSpec = tween(ANIMATION_DURATION)
)
)
},
popTransitionSpec = {
// Default pop: scale up from background, current slides out right
(
fadeIn(animationSpec = tween(ANIMATION_DURATION)) +
scaleIn(
initialScale = 0.9f,
animationSpec = tween(ANIMATION_DURATION)
)
) togetherWith (
slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(ANIMATION_DURATION)
) + fadeOut(animationSpec = tween(ANIMATION_DURATION))
)
},
predictivePopTransitionSpec = {
(
fadeIn(animationSpec = tween(ANIMATION_DURATION)) +
scaleIn(
initialScale = 0.9f,
animationSpec = tween(ANIMATION_DURATION)
)
) togetherWith (
slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(ANIMATION_DURATION)
) + fadeOut(animationSpec = tween(ANIMATION_DURATION))
)
}
)
}
/**
* Transition spec for bottom sheet style screens that slide up from the bottom.
*/
private val BottomSheetTransitionSpec = NavDisplay.transitionSpec {
(
slideInVertically(
initialOffsetY = { it },
animationSpec = tween(ANIMATION_DURATION)
) + fadeIn(animationSpec = tween(ANIMATION_DURATION))
) togetherWith ExitTransition.KeepUntilTransitionsFinished
} + NavDisplay.popTransitionSpec {
EnterTransition.None togetherWith (
slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(ANIMATION_DURATION)
) + fadeOut(animationSpec = tween(ANIMATION_DURATION))
)
} + NavDisplay.predictivePopTransitionSpec {
EnterTransition.None togetherWith (
slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(ANIMATION_DURATION)
) + fadeOut(animationSpec = tween(ANIMATION_DURATION))
)
}

View File

@@ -0,0 +1,112 @@
/*
* 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.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
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.debug.DebugNetworkController
import org.signal.registration.sample.dependencies.RealNetworkController
import org.signal.registration.sample.dependencies.RealStorageController
import org.signal.registration.sample.storage.RegistrationPreferences
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() {
companion object {
// Staging SVR2 mrEnclave value
private const val SVR2_MRENCLAVE = "a75542d82da9f6914a1e31f8a7407053b99cc99a0e7291d8fbd394253e19b036"
}
override fun onCreate() {
super.onCreate()
Log.initialize(AndroidLogger)
RegistrationPreferences.init(this)
val trustStore = SampleTrustStore()
val configuration = createServiceConfiguration(trustStore)
val pushServiceSocket = createPushServiceSocket(configuration)
val realNetworkController = RealNetworkController(this, pushServiceSocket, configuration, SVR2_MRENCLAVE)
val networkController = DebugNetworkController(realNetworkController)
val storageController = RealStorageController(this)
RegistrationDependencies.provide(
RegistrationDependencies(
networkController = networkController,
storageController = storageController
)
)
}
private fun createPushServiceSocket(configuration: SignalServiceConfiguration): PushServiceSocket {
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,192 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.debug
import org.signal.core.models.MasterKey
import org.signal.core.util.logging.Log
import org.signal.registration.NetworkController
import org.signal.registration.NetworkController.AccountAttributes
import org.signal.registration.NetworkController.BackupMasterKeyError
import org.signal.registration.NetworkController.CreateSessionError
import org.signal.registration.NetworkController.GetSessionStatusError
import org.signal.registration.NetworkController.GetSvrCredentialsError
import org.signal.registration.NetworkController.MasterKeyResponse
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.RestoreMasterKeyError
import org.signal.registration.NetworkController.SessionMetadata
import org.signal.registration.NetworkController.SetAccountAttributesError
import org.signal.registration.NetworkController.SetRegistrationLockError
import org.signal.registration.NetworkController.SubmitVerificationCodeError
import org.signal.registration.NetworkController.SvrCredentials
import org.signal.registration.NetworkController.UpdateSessionError
import org.signal.registration.NetworkController.VerificationCodeTransport
import java.util.Locale
/**
* Debug wrapper for NetworkController that allows forcing specific responses.
*
* When an override is set for a method via [NetworkDebugState], this controller
* returns the forced result instead of calling the delegate.
*
* This is useful for testing error handling, edge cases, and UI states without
* needing a real backend connection.
*/
class DebugNetworkController(
private val delegate: NetworkController
) : NetworkController {
companion object {
private val TAG = Log.tag(DebugNetworkController::class)
}
override suspend fun createSession(
e164: String,
fcmToken: String?,
mcc: String?,
mnc: String?
): RegistrationNetworkResult<SessionMetadata, CreateSessionError> {
NetworkDebugState.getOverride<RegistrationNetworkResult<SessionMetadata, CreateSessionError>>("createSession")?.let {
Log.d(TAG, "[createSession] Returning debug override")
return it
}
return delegate.createSession(e164, fcmToken, mcc, mnc)
}
override suspend fun getSession(sessionId: String): RegistrationNetworkResult<SessionMetadata, GetSessionStatusError> {
NetworkDebugState.getOverride<RegistrationNetworkResult<SessionMetadata, GetSessionStatusError>>("getSession")?.let {
Log.d(TAG, "[getSession] Returning debug override")
return it
}
return delegate.getSession(sessionId)
}
override suspend fun updateSession(
sessionId: String?,
pushChallengeToken: String?,
captchaToken: String?
): RegistrationNetworkResult<SessionMetadata, UpdateSessionError> {
NetworkDebugState.getOverride<RegistrationNetworkResult<SessionMetadata, UpdateSessionError>>("updateSession")?.let {
Log.d(TAG, "[updateSession] Returning debug override")
return it
}
return delegate.updateSession(sessionId, pushChallengeToken, captchaToken)
}
override suspend fun requestVerificationCode(
sessionId: String,
locale: Locale?,
androidSmsRetrieverSupported: Boolean,
transport: VerificationCodeTransport
): RegistrationNetworkResult<SessionMetadata, RequestVerificationCodeError> {
NetworkDebugState.getOverride<RegistrationNetworkResult<SessionMetadata, RequestVerificationCodeError>>("requestVerificationCode")?.let {
Log.d(TAG, "[requestVerificationCode] Returning debug override")
return it
}
return delegate.requestVerificationCode(sessionId, locale, androidSmsRetrieverSupported, transport)
}
override suspend fun submitVerificationCode(
sessionId: String,
verificationCode: String
): RegistrationNetworkResult<SessionMetadata, SubmitVerificationCodeError> {
NetworkDebugState.getOverride<RegistrationNetworkResult<SessionMetadata, SubmitVerificationCodeError>>("submitVerificationCode")?.let {
Log.d(TAG, "[submitVerificationCode] Returning debug override")
return it
}
return delegate.submitVerificationCode(sessionId, verificationCode)
}
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> {
NetworkDebugState.getOverride<RegistrationNetworkResult<RegisterAccountResponse, RegisterAccountError>>("registerAccount")?.let {
Log.d(TAG, "[registerAccount] Returning debug override")
return it
}
return delegate.registerAccount(e164, password, sessionId, recoveryPassword, attributes, aciPreKeys, pniPreKeys, fcmToken, skipDeviceTransfer)
}
override suspend fun getFcmToken(): String? {
// No override support for simple value methods
return delegate.getFcmToken()
}
override suspend fun awaitPushChallengeToken(): String? {
// No override support for simple value methods
return delegate.awaitPushChallengeToken()
}
override fun getCaptchaUrl(): String {
// No override support for simple value methods
return delegate.getCaptchaUrl()
}
override suspend fun restoreMasterKeyFromSvr(
svr2Credentials: SvrCredentials,
pin: String
): RegistrationNetworkResult<MasterKeyResponse, RestoreMasterKeyError> {
NetworkDebugState.getOverride<RegistrationNetworkResult<MasterKeyResponse, RestoreMasterKeyError>>("restoreMasterKeyFromSvr")?.let {
Log.d(TAG, "[restoreMasterKeyFromSvr] Returning debug override")
return it
}
return delegate.restoreMasterKeyFromSvr(svr2Credentials, pin)
}
override suspend fun setPinAndMasterKeyOnSvr(
pin: String,
masterKey: MasterKey
): RegistrationNetworkResult<Unit, BackupMasterKeyError> {
NetworkDebugState.getOverride<RegistrationNetworkResult<Unit, BackupMasterKeyError>>("setPinAndMasterKeyOnSvr")?.let {
Log.d(TAG, "[setPinAndMasterKeyOnSvr] Returning debug override")
return it
}
return delegate.setPinAndMasterKeyOnSvr(pin, masterKey)
}
override suspend fun enableRegistrationLock(): RegistrationNetworkResult<Unit, SetRegistrationLockError> {
NetworkDebugState.getOverride<RegistrationNetworkResult<Unit, SetRegistrationLockError>>("enableRegistrationLock")?.let {
Log.d(TAG, "[enableRegistrationLock] Returning debug override")
return it
}
return delegate.enableRegistrationLock()
}
override suspend fun disableRegistrationLock(): RegistrationNetworkResult<Unit, SetRegistrationLockError> {
NetworkDebugState.getOverride<RegistrationNetworkResult<Unit, SetRegistrationLockError>>("disableRegistrationLock")?.let {
Log.d(TAG, "[disableRegistrationLock] Returning debug override")
return it
}
return delegate.disableRegistrationLock()
}
override suspend fun setAccountAttributes(attributes: AccountAttributes): RegistrationNetworkResult<Unit, SetAccountAttributesError> {
NetworkDebugState.getOverride<RegistrationNetworkResult<Unit, SetAccountAttributesError>>("setAccountAttributes")?.let {
Log.d(TAG, "[setAccountAttributes] Returning debug override")
return it
}
return delegate.setAccountAttributes(attributes)
}
override suspend fun getSvrCredentials(): RegistrationNetworkResult<SvrCredentials, GetSvrCredentialsError> {
NetworkDebugState.getOverride<RegistrationNetworkResult<SvrCredentials, GetSvrCredentialsError>>("getSvrCredentials")?.let {
Log.d(TAG, "[getSvrCredentials] Returning debug override")
return it
}
return delegate.getSvrCredentials()
}
}

View File

@@ -0,0 +1,198 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.debug
import org.signal.registration.NetworkController
import org.signal.registration.NetworkController.RegistrationLockResponse
import org.signal.registration.NetworkController.RegistrationNetworkResult
import org.signal.registration.NetworkController.SessionMetadata
import org.signal.registration.NetworkController.SvrCredentials
import org.signal.registration.NetworkController.ThirdPartyServiceErrorResponse
import java.io.IOException
import kotlin.reflect.KClass
import kotlin.reflect.KParameter
import kotlin.reflect.KType
import kotlin.reflect.full.memberFunctions
import kotlin.reflect.full.primaryConstructor
import kotlin.reflect.full.valueParameters
import kotlin.reflect.jvm.jvmErasure
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Mock data factory for debug overrides.
* Uses reflection to automatically discover NetworkController methods and their error types.
* Only provides failure/error options - success cases are not overridable.
*/
object DebugNetworkMockData {
// ============================================
// Mock data for error type constructor params
// ============================================
private val mockSessionMetadata = SessionMetadata(
id = "mock-session-id-12345",
nextSms = System.currentTimeMillis() + 60_000,
nextCall = System.currentTimeMillis() + 120_000,
nextVerificationAttempt = System.currentTimeMillis() + 30_000,
allowedToRequestCode = true,
requestedInformation = emptyList(),
verified = false
)
private val mockSvrCredentials = SvrCredentials(
username = "mock-svr-username",
password = "mock-svr-password"
)
private val mockRegistrationLockResponse = RegistrationLockResponse(
timeRemaining = 86400000L,
svr2Credentials = mockSvrCredentials
)
// ============================================
// Data Classes
// ============================================
data class ResultOption(
val name: String,
val displayName: String,
val createResult: () -> Any
)
data class MethodOverrideInfo(
val methodName: String,
val displayName: String,
val options: List<ResultOption>
)
// ============================================
// Reflection-based Method Discovery
// ============================================
val allMethods: List<MethodOverrideInfo> by lazy {
discoverMethods()
}
private fun discoverMethods(): List<MethodOverrideInfo> {
return NetworkController::class.memberFunctions
.filter { it.returnType.isRegistrationNetworkResult() }
.map { function ->
val methodName = function.name
val (_, errorType) = extractResultTypes(function.returnType)
val options = buildOptionsForMethod(methodName, errorType)
MethodOverrideInfo(methodName, methodName, options)
}
.sortedBy { it.methodName }
}
private fun KType.isRegistrationNetworkResult(): Boolean {
return this.jvmErasure == RegistrationNetworkResult::class
}
private fun extractResultTypes(returnType: KType): Pair<KClass<*>?, KClass<*>?> {
val typeArgs = returnType.arguments
val successType = typeArgs.getOrNull(0)?.type?.jvmErasure
val errorType = typeArgs.getOrNull(1)?.type?.jvmErasure
return successType to errorType
}
private fun buildOptionsForMethod(
methodName: String,
errorType: KClass<*>?
): List<ResultOption> {
val options = mutableListOf<ResultOption>()
// Always add "Unset" first
options.add(ResultOption("unset", "Unset") { Unit })
// Add error options from sealed subclasses
if (errorType != null) {
val errorOptions = generateErrorOptions(errorType)
options.addAll(errorOptions)
}
// Always add NetworkError and ApplicationError
options.add(
ResultOption("network_error", "NetworkError") {
RegistrationNetworkResult.NetworkError(IOException("Mock network error"))
}
)
options.add(
ResultOption("application_error", "ApplicationError") {
RegistrationNetworkResult.ApplicationError(RuntimeException("Mock application error"))
}
)
return options
}
private fun generateErrorOptions(errorType: KClass<*>): List<ResultOption> {
return errorType.sealedSubclasses.mapNotNull { subclass ->
val instance = createMockInstance(subclass)
if (instance != null) {
val name = subclass.simpleName?.toSnakeCase() ?: return@mapNotNull null
val displayName = subclass.simpleName ?: return@mapNotNull null
ResultOption(name, displayName) {
RegistrationNetworkResult.Failure(instance)
}
} else {
null
}
}
}
private fun createMockInstance(klass: KClass<*>): Any? {
// Try object instance first (for data objects)
klass.objectInstance?.let { return it }
// Try primary constructor
val constructor = klass.primaryConstructor ?: return null
return try {
if (constructor.parameters.isEmpty()) {
constructor.call()
} else {
val args = constructor.valueParameters.associateWith { param ->
createMockValueForParameter(param)
}
constructor.callBy(args)
}
} catch (e: Exception) {
null
}
}
private fun createMockValueForParameter(param: KParameter): Any? {
val type = param.type.jvmErasure
return when {
type == String::class -> "Mock ${param.name}"
type == Int::class -> 3
type == Long::class -> 60000L
type == Boolean::class -> false
type == Duration::class -> 60.seconds
type == SessionMetadata::class -> mockSessionMetadata
type == SvrCredentials::class -> mockSvrCredentials
type == RegistrationLockResponse::class -> mockRegistrationLockResponse
type == ThirdPartyServiceErrorResponse::class -> ThirdPartyServiceErrorResponse(
reason = "Mock third party error",
permanentFailure = false
)
param.type.isMarkedNullable -> null
else -> null
}
}
private fun String.toSnakeCase(): String {
return this.fold(StringBuilder()) { acc, c ->
if (c.isUpperCase() && acc.isNotEmpty()) {
acc.append('_')
}
acc.append(c.lowercase())
}.toString()
}
}

View File

@@ -0,0 +1,327 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.debug
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import kotlin.math.roundToInt
/**
* Debug overlay that provides a draggable floating button to access network override controls.
* When tapped, opens a dialog allowing developers to force specific network responses.
*/
@Composable
fun NetworkDebugOverlay(
modifier: Modifier = Modifier
) {
var showDialog by remember { mutableStateOf(false) }
var dragOffset by remember { mutableStateOf(Offset.Zero) }
Box(modifier = modifier) {
DebugFloatingButton(
onClick = { showDialog = true },
dragOffset = dragOffset,
onDrag = { delta -> dragOffset += delta },
modifier = Modifier.align(Alignment.BottomEnd)
)
if (showDialog) {
NetworkDebugDialog(
onDismiss = { showDialog = false }
)
}
}
}
@Composable
private fun DebugFloatingButton(
onClick: () -> Unit,
dragOffset: Offset,
onDrag: (Offset) -> Unit,
modifier: Modifier = Modifier
) {
val overrideSelections by NetworkDebugState.overrideSelections.collectAsState()
val hasActiveOverrides = overrideSelections.isNotEmpty()
FilledTonalIconButton(
onClick = onClick,
modifier = modifier
.offset { IntOffset(dragOffset.x.roundToInt(), dragOffset.y.roundToInt()) }
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
onDrag(dragAmount)
}
}
.padding(16.dp)
.size(56.dp),
shape = RoundedCornerShape(18.dp)
) {
Box {
Text(
text = "\uD83D\uDD27", // Wrench emoji
style = MaterialTheme.typography.titleLarge
)
if (hasActiveOverrides) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.size(10.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.error)
)
}
}
}
}
@Composable
private fun NetworkDebugDialog(
onDismiss: () -> Unit
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
usePlatformDefaultWidth = false
)
) {
Surface(
modifier = Modifier
.fillMaxWidth(0.95f)
.padding(16.dp),
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp
) {
Column(
modifier = Modifier.padding(24.dp)
) {
Text(
text = "Network Debug",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Force specific responses from NetworkController methods",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = false),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(DebugNetworkMockData.allMethods) { methodInfo ->
MethodOverrideRow(methodInfo = methodInfo)
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(
onClick = { NetworkDebugState.clearAllOverrides() }
) {
Text("Clear All")
}
Spacer(modifier = Modifier.width(8.dp))
TextButton(onClick = onDismiss) {
Text("Close")
}
}
}
}
}
}
@Composable
private fun MethodOverrideRow(
methodInfo: DebugNetworkMockData.MethodOverrideInfo
) {
val overrideSelections by NetworkDebugState.overrideSelections.collectAsState()
val currentSelection = overrideSelections[methodInfo.methodName] ?: "unset"
val currentOption = methodInfo.options.find { it.name == currentSelection }
?: methodInfo.options.first()
var expanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Text(
text = methodInfo.displayName,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Box {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(
if (currentSelection != "unset") {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
}
)
.clickable { expanded = true }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = currentOption.displayName,
style = MaterialTheme.typography.bodyMedium,
color = if (currentSelection != "unset") {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.weight(1f)
)
Text(
text = "",
color = if (currentSelection != "unset") {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.fillMaxWidth(0.8f)
) {
methodInfo.options.forEach { option ->
DropdownMenuItem(
text = {
Text(
text = option.displayName,
fontWeight = if (option.name == currentSelection) FontWeight.Bold else FontWeight.Normal
)
},
onClick = {
if (option.name == "unset") {
NetworkDebugState.clearOverride(methodInfo.methodName)
} else {
NetworkDebugState.setOverride(
methodName = methodInfo.methodName,
optionName = option.name,
result = option.createResult()
)
}
expanded = false
}
)
}
}
}
}
}
}
@DayNightPreviews
@Composable
private fun NetworkDebugOverlayPreview() {
Previews.Preview {
NetworkDebugOverlay(
modifier = Modifier.fillMaxWidth()
)
}
}
@DayNightPreviews
@Composable
private fun NetworkDebugDialogPreview() {
Previews.Preview {
NetworkDebugDialog(
onDismiss = {}
)
}
}
@DayNightPreviews
@Composable
private fun MethodOverrideRowPreview() {
Previews.Preview {
MethodOverrideRow(
methodInfo = DebugNetworkMockData.allMethods.first()
)
}
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.debug
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* Singleton state manager for network debug overrides.
* Tracks which methods have forced responses set.
*/
object NetworkDebugState {
/**
* Map of method name to the selected option name (e.g., "createSession" -> "success")
* A value of "unset" or absence means no override is active.
*/
private val _overrideSelections = MutableStateFlow<Map<String, String>>(emptyMap())
val overrideSelections: StateFlow<Map<String, String>> = _overrideSelections.asStateFlow()
/**
* Map of method name to the actual result object to return.
* This is populated when setOverride is called.
*/
private val _overrideResults = MutableStateFlow<Map<String, Any>>(emptyMap())
val overrideResults: StateFlow<Map<String, Any>> = _overrideResults.asStateFlow()
/**
* Set an override for a specific method.
*
* @param methodName The name of the NetworkController method
* @param optionName The name of the selected option (e.g., "success", "rate_limited")
* @param result The actual result object to return, or null to clear the override
*/
fun setOverride(methodName: String, optionName: String, result: Any?) {
if (optionName == "unset" || result == null || result == Unit) {
clearOverride(methodName)
} else {
_overrideSelections.update { it + (methodName to optionName) }
_overrideResults.update { it + (methodName to result) }
}
}
/**
* Clear the override for a specific method.
*/
fun clearOverride(methodName: String) {
_overrideSelections.update { it - methodName }
_overrideResults.update { it - methodName }
}
/**
* Clear all overrides.
*/
fun clearAllOverrides() {
_overrideSelections.value = emptyMap()
_overrideResults.value = emptyMap()
}
/**
* Get the current override result for a method, if any.
*/
@Suppress("UNCHECKED_CAST")
fun <T> getOverride(methodName: String): T? {
return _overrideResults.value[methodName] as? T
}
/**
* Check if a method has an active override.
*/
fun hasOverride(methodName: String): Boolean {
return _overrideResults.value.containsKey(methodName)
}
/**
* Get the selected option name for a method.
*/
fun getSelectedOption(methodName: String): String {
return _overrideSelections.value[methodName] ?: "unset"
}
}

View File

@@ -0,0 +1,724 @@
/*
* 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.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.signal.core.models.MasterKey
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.Network
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.signal.registration.sample.fcm.FcmUtil
import org.signal.registration.sample.fcm.PushChallengeReceiver
import org.signal.registration.sample.storage.RegistrationPreferences
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.RestoreResponse
import org.whispersystems.signalservice.api.svr.SecureValueRecoveryV2
import org.whispersystems.signalservice.api.util.SleepTimer
import org.whispersystems.signalservice.api.websocket.HealthMonitor
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.api.websocket.WebSocketFactory
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider
import org.whispersystems.signalservice.internal.websocket.LibSignalChatConnection
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 context: android.content.Context,
private val pushServiceSocket: PushServiceSocket,
private val serviceConfiguration: SignalServiceConfiguration,
private val svr2MrEnclave: String
) : NetworkController {
companion object {
private val TAG = Log.tag(RealNetworkController::class)
}
private val json = Json { ignoreUnknownKeys = true }
private val okHttpClient: okhttp3.OkHttpClient by lazy {
val trustStore = serviceConfiguration.signalServiceUrls[0].trustStore
val keyStore = java.security.KeyStore.getInstance(java.security.KeyStore.getDefaultType())
keyStore.load(trustStore.keyStoreInputStream, trustStore.keyStorePassword.toCharArray())
val tmf = javax.net.ssl.TrustManagerFactory.getInstance(javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm())
tmf.init(keyStore)
val sslContext = javax.net.ssl.SSLContext.getInstance("TLS")
sslContext.init(null, tmf.trustManagers, null)
val trustManager = tmf.trustManagers[0] as javax.net.ssl.X509TrustManager
okhttp3.OkHttpClient.Builder()
.sslSocketFactory(sslContext.socketFactory, trustManager)
.build()
}
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)
}
422 -> {
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.InvalidSessionIdOrVerificationCode(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)
}
401 -> {
RegistrationNetworkResult.Failure(RegisterAccountError.SessionNotFoundOrNotVerified(response.body.string()))
}
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 try {
FcmUtil.getToken(context)
} catch (e: Exception) {
Log.w(TAG, "Failed to get FCM token", e)
null
}
}
override suspend fun awaitPushChallengeToken(): String? {
return try {
PushChallengeReceiver.awaitChallenge()
} catch (e: Exception) {
Log.w(TAG, "Failed to await push challenge token", e)
null
}
}
override fun getCaptchaUrl(): String {
return "https://signalcaptchas.org/staging/registration/generate.html"
}
override suspend fun restoreMasterKeyFromSvr(
svr2Credentials: NetworkController.SvrCredentials,
pin: String
): RegistrationNetworkResult<NetworkController.MasterKeyResponse, NetworkController.RestoreMasterKeyError> = withContext(Dispatchers.IO) {
try {
val authCredentials = AuthCredentials.create(svr2Credentials.username, svr2Credentials.password)
// Create a stub websocket that will never be used for pre-registration restore
val stubWebSocketFactory = WebSocketFactory { throw UnsupportedOperationException("WebSocket not available during pre-registration") }
val stubWebSocket = SignalWebSocket.AuthenticatedWebSocket(
stubWebSocketFactory,
{ false },
object : SleepTimer {
override fun sleep(millis: Long) = Thread.sleep(millis)
},
0
)
val svr2 = SecureValueRecoveryV2(serviceConfiguration, svr2MrEnclave, stubWebSocket)
when (val response = svr2.restoreDataPreRegistration(authCredentials, null, pin)) {
is RestoreResponse.Success -> {
Log.i(TAG, "[restoreMasterKeyFromSvr] Successfully restored master key from SVR2")
RegistrationNetworkResult.Success(NetworkController.MasterKeyResponse(response.masterKey))
}
is RestoreResponse.PinMismatch -> {
Log.w(TAG, "[restoreMasterKeyFromSvr] PIN mismatch. Tries remaining: ${response.triesRemaining}")
RegistrationNetworkResult.Failure(NetworkController.RestoreMasterKeyError.WrongPin(response.triesRemaining))
}
is RestoreResponse.Missing -> {
Log.w(TAG, "[restoreMasterKeyFromSvr] No SVR data found for user")
RegistrationNetworkResult.Failure(NetworkController.RestoreMasterKeyError.NoDataFound)
}
is RestoreResponse.NetworkError -> {
Log.w(TAG, "[restoreMasterKeyFromSvr] Network error", response.exception)
RegistrationNetworkResult.NetworkError(response.exception)
}
is RestoreResponse.ApplicationError -> {
Log.w(TAG, "[restoreMasterKeyFromSvr] Application error", response.exception)
RegistrationNetworkResult.ApplicationError(response.exception)
}
is RestoreResponse.EnclaveNotFound -> {
Log.w(TAG, "[restoreMasterKeyFromSvr] Enclave not found")
RegistrationNetworkResult.ApplicationError(IllegalStateException("SVR2 enclave not found"))
}
}
} catch (e: IOException) {
Log.w(TAG, "[restoreMasterKeyFromSvr] IOException", e)
RegistrationNetworkResult.NetworkError(e)
} catch (e: Exception) {
Log.w(TAG, "[restoreMasterKeyFromSvr] Exception", e)
RegistrationNetworkResult.ApplicationError(e)
}
}
override suspend fun setPinAndMasterKeyOnSvr(
pin: String,
masterKey: MasterKey
): RegistrationNetworkResult<Unit, NetworkController.BackupMasterKeyError> = withContext(Dispatchers.IO) {
try {
val aci = RegistrationPreferences.aci
val pni = RegistrationPreferences.pni
val e164 = RegistrationPreferences.e164
val password = RegistrationPreferences.servicePassword
if (aci == null || e164 == null || password == null) {
Log.w(TAG, "[backupMasterKeyToSvr] Credentials not available, cannot authenticate")
return@withContext RegistrationNetworkResult.Failure(NetworkController.BackupMasterKeyError.NotRegistered)
}
val network = Network(Network.Environment.STAGING, "Signal-Android-Registration-Sample", emptyMap(), Network.BuildVariant.PRODUCTION)
val credentialsProvider = StaticCredentialsProvider(aci, pni, e164, 1, password)
val healthMonitor = object : HealthMonitor {
override fun onKeepAliveResponse(sentTimestamp: Long, isIdentifiedWebSocket: Boolean) {}
override fun onMessageError(status: Int, isIdentifiedWebSocket: Boolean) {}
}
val libSignalConnection = LibSignalChatConnection(
name = "SVR-Backup",
network = network,
credentialsProvider = credentialsProvider,
receiveStories = false,
healthMonitor = healthMonitor
)
val authWebSocket = SignalWebSocket.AuthenticatedWebSocket(
connectionFactory = { libSignalConnection },
canConnect = { true },
sleepTimer = { millis -> Thread.sleep(millis) },
disconnectTimeoutMs = 60.seconds.inWholeMilliseconds
)
authWebSocket.connect()
val svr2 = SecureValueRecoveryV2(serviceConfiguration, svr2MrEnclave, authWebSocket)
val session = svr2.setPin(pin, masterKey)
val response = session.execute()
authWebSocket.disconnect()
when (response) {
is BackupResponse.Success -> {
Log.i(TAG, "[backupMasterKeyToSvr] Successfully backed up master key to SVR2")
RegistrationNetworkResult.Success(Unit)
}
is BackupResponse.ApplicationError -> {
Log.w(TAG, "[backupMasterKeyToSvr] Application error", response.exception)
RegistrationNetworkResult.ApplicationError(response.exception)
}
is BackupResponse.NetworkError -> {
Log.w(TAG, "[backupMasterKeyToSvr] Network error", response.exception)
RegistrationNetworkResult.NetworkError(response.exception)
}
is BackupResponse.EnclaveNotFound -> {
Log.w(TAG, "[backupMasterKeyToSvr] Enclave not found")
RegistrationNetworkResult.Failure(NetworkController.BackupMasterKeyError.EnclaveNotFound)
}
is BackupResponse.ExposeFailure -> {
Log.w(TAG, "[backupMasterKeyToSvr] Expose failure -- per spec, treat as success.")
RegistrationNetworkResult.Success(Unit)
}
is BackupResponse.ServerRejected -> {
Log.w(TAG, "[backupMasterKeyToSvr] Server rejected")
RegistrationNetworkResult.NetworkError(IOException("Server rejected backup request"))
}
}
} catch (e: IOException) {
Log.w(TAG, "[backupMasterKeyToSvr] IOException", e)
RegistrationNetworkResult.NetworkError(e)
} catch (e: Exception) {
Log.w(TAG, "[backupMasterKeyToSvr] Exception", e)
RegistrationNetworkResult.ApplicationError(e)
}
}
override suspend fun enableRegistrationLock(): RegistrationNetworkResult<Unit, NetworkController.SetRegistrationLockError> = withContext(Dispatchers.IO) {
val aci = RegistrationPreferences.aci
val password = RegistrationPreferences.servicePassword
val masterKey = RegistrationPreferences.masterKey
if (aci == null || password == null) {
Log.w(TAG, "[enableRegistrationLock] Credentials not available")
return@withContext RegistrationNetworkResult.Failure(NetworkController.SetRegistrationLockError.NotRegistered)
}
if (masterKey == null) {
Log.w(TAG, "[enableRegistrationLock] Master key not available")
return@withContext RegistrationNetworkResult.Failure(NetworkController.SetRegistrationLockError.NoPinSet)
}
val registrationLockToken = masterKey.deriveRegistrationLock()
try {
val credentials = okhttp3.Credentials.basic(aci.toString(), password)
val baseUrl = serviceConfiguration.signalServiceUrls[0].url
val requestBody = """{"registrationLock":"$registrationLockToken"}"""
.toRequestBody("application/json".toMediaType())
val request = okhttp3.Request.Builder()
.url("$baseUrl/v1/accounts/registration_lock")
.put(requestBody)
.header("Authorization", credentials)
.build()
okHttpClient.newCall(request).execute().use { response ->
when (response.code) {
200, 204 -> {
Log.i(TAG, "[enableRegistrationLock] Successfully enabled registration lock")
RegistrationNetworkResult.Success(Unit)
}
401 -> {
RegistrationNetworkResult.Failure(NetworkController.SetRegistrationLockError.Unauthorized)
}
422 -> {
RegistrationNetworkResult.Failure(NetworkController.SetRegistrationLockError.InvalidRequest(response.body?.string() ?: ""))
}
else -> {
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}"))
}
}
}
} catch (e: IOException) {
Log.w(TAG, "[enableRegistrationLock] IOException", e)
RegistrationNetworkResult.NetworkError(e)
} catch (e: Exception) {
Log.w(TAG, "[enableRegistrationLock] Exception", e)
RegistrationNetworkResult.ApplicationError(e)
}
}
override suspend fun disableRegistrationLock(): RegistrationNetworkResult<Unit, NetworkController.SetRegistrationLockError> = withContext(Dispatchers.IO) {
val aci = RegistrationPreferences.aci
val password = RegistrationPreferences.servicePassword
if (aci == null || password == null) {
Log.w(TAG, "[disableRegistrationLock] Credentials not available")
return@withContext RegistrationNetworkResult.Failure(NetworkController.SetRegistrationLockError.NotRegistered)
}
try {
val credentials = okhttp3.Credentials.basic(aci.toString(), password)
val baseUrl = serviceConfiguration.signalServiceUrls[0].url
val request = okhttp3.Request.Builder()
.url("$baseUrl/v1/accounts/registration_lock")
.delete()
.header("Authorization", credentials)
.build()
okHttpClient.newCall(request).execute().use { response ->
when (response.code) {
200, 204 -> {
Log.i(TAG, "[disableRegistrationLock] Successfully disabled registration lock")
RegistrationNetworkResult.Success(Unit)
}
401 -> {
RegistrationNetworkResult.Failure(NetworkController.SetRegistrationLockError.Unauthorized)
}
else -> {
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}"))
}
}
}
} catch (e: IOException) {
Log.w(TAG, "[disableRegistrationLock] IOException", e)
RegistrationNetworkResult.NetworkError(e)
} catch (e: Exception) {
Log.w(TAG, "[disableRegistrationLock] Exception", e)
RegistrationNetworkResult.ApplicationError(e)
}
}
override suspend fun setAccountAttributes(
attributes: AccountAttributes
): RegistrationNetworkResult<Unit, NetworkController.SetAccountAttributesError> = withContext(Dispatchers.IO) {
val aci = RegistrationPreferences.aci
val password = RegistrationPreferences.servicePassword
if (aci == null || password == null) {
Log.w(TAG, "[setAccountAttributes] Credentials not available")
return@withContext RegistrationNetworkResult.Failure(NetworkController.SetAccountAttributesError.Unauthorized)
}
try {
val credentials = okhttp3.Credentials.basic(aci.toString(), password)
val baseUrl = serviceConfiguration.signalServiceUrls[0].url
val requestBody = json.encodeToString(AccountAttributes.serializer(), attributes)
.toRequestBody("application/json".toMediaType())
val request = okhttp3.Request.Builder()
.url("$baseUrl/v1/accounts/attributes")
.put(requestBody)
.header("Authorization", credentials)
.build()
okHttpClient.newCall(request).execute().use { response ->
when (response.code) {
200, 204 -> {
Log.i(TAG, "[setAccountAttributes] Successfully updated account attributes")
RegistrationNetworkResult.Success(Unit)
}
401 -> {
RegistrationNetworkResult.Failure(NetworkController.SetAccountAttributesError.Unauthorized)
}
422 -> {
RegistrationNetworkResult.Failure(NetworkController.SetAccountAttributesError.InvalidRequest(response.body?.string() ?: ""))
}
else -> {
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body?.string()}"))
}
}
}
} catch (e: IOException) {
Log.w(TAG, "[setAccountAttributes] IOException", e)
RegistrationNetworkResult.NetworkError(e)
} catch (e: Exception) {
Log.w(TAG, "[setAccountAttributes] Exception", e)
RegistrationNetworkResult.ApplicationError(e)
}
}
override suspend fun getSvrCredentials(): RegistrationNetworkResult<NetworkController.SvrCredentials, NetworkController.GetSvrCredentialsError> = withContext(Dispatchers.IO) {
val aci = RegistrationPreferences.aci
val password = RegistrationPreferences.servicePassword
if (aci == null || password == null) {
Log.w(TAG, "[getSvrCredentials] Credentials not available")
return@withContext RegistrationNetworkResult.Failure(NetworkController.GetSvrCredentialsError.NoServiceCredentialsAvailable)
}
try {
val credentials = okhttp3.Credentials.basic(aci.toString(), password)
val baseUrl = serviceConfiguration.signalServiceUrls[0].url
val request = okhttp3.Request.Builder()
.url("$baseUrl/v2/svr/auth")
.get()
.header("Authorization", credentials)
.build()
okHttpClient.newCall(request).execute().use { response ->
when (response.code) {
200 -> {
val svrCredentials = json.decodeFromString<NetworkController.SvrCredentials>(response.body.string())
RegistrationNetworkResult.Success(svrCredentials)
}
401 -> {
RegistrationNetworkResult.Failure(NetworkController.GetSvrCredentialsError.Unauthorized)
}
else -> {
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
}
}
}
} catch (e: IOException) {
Log.w(TAG, "[getSvrCredentials] IOException", e)
RegistrationNetworkResult.NetworkError(e)
} catch (e: Exception) {
Log.w(TAG, "[getSvrCredentials] Exception", e)
RegistrationNetworkResult.ApplicationError(e)
}
}
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,176 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.dependencies
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
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.NewRegistrationData
import org.signal.registration.PreExistingRegistrationData
import org.signal.registration.StorageController
import org.signal.registration.sample.storage.RegistrationDatabase
import org.signal.registration.sample.storage.RegistrationPreferences
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 using
* SharedPreferences for simple key-value data and SQLite for prekeys.
*/
class RealStorageController(context: Context) : StorageController {
private val db = RegistrationDatabase(context)
override suspend fun generateAndStoreKeyMaterial(): KeyMaterial = withContext(Dispatchers.IO) {
val accountEntropyPool = AccountEntropyPool.generate()
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,
accountEntropyPool = accountEntropyPool
)
storeKeyMaterial(keyMaterial, profileKey)
keyMaterial
}
override suspend fun saveNewRegistrationData(newRegistrationData: NewRegistrationData) = withContext(Dispatchers.IO) {
RegistrationPreferences.saveRegistrationData(newRegistrationData)
}
override suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? = withContext(Dispatchers.IO) {
RegistrationPreferences.getPreExistingRegistrationData()
}
override suspend fun clearAllData() = withContext(Dispatchers.IO) {
RegistrationPreferences.clearAll()
db.clearAllPreKeys()
}
override suspend fun saveValidatedPinAndTemporaryMasterKey(pin: String, isAlphanumeric: Boolean, masterKey: MasterKey, registrationLockEnabled: Boolean) = withContext(Dispatchers.IO) {
RegistrationPreferences.pin = pin
RegistrationPreferences.pinAlphanumeric = isAlphanumeric
RegistrationPreferences.temporaryMasterKey = masterKey
RegistrationPreferences.registrationLockEnabled = registrationLockEnabled
}
override suspend fun saveNewlyCreatedPin(pin: String, isAlphanumeric: Boolean) {
RegistrationPreferences.pin = pin
RegistrationPreferences.pinAlphanumeric = isAlphanumeric
}
private fun storeKeyMaterial(keyMaterial: KeyMaterial, profileKey: ProfileKey) {
// Clear existing data
RegistrationPreferences.clearKeyMaterial()
db.clearAllPreKeys()
// Store in SharedPreferences
RegistrationPreferences.aciIdentityKeyPair = keyMaterial.aciIdentityKeyPair
RegistrationPreferences.pniIdentityKeyPair = keyMaterial.pniIdentityKeyPair
RegistrationPreferences.aciRegistrationId = keyMaterial.aciRegistrationId
RegistrationPreferences.pniRegistrationId = keyMaterial.pniRegistrationId
RegistrationPreferences.profileKey = profileKey
// Store prekeys in database
db.signedPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_ACI, keyMaterial.aciSignedPreKey)
db.signedPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_PNI, keyMaterial.pniSignedPreKey)
db.kyberPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_ACI, keyMaterial.aciLastResortKyberPreKey)
db.kyberPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_PNI, keyMaterial.pniLastResortKyberPreKey)
}
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)
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.fcm
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import org.signal.core.util.logging.Log
/**
* Firebase Cloud Messaging service for receiving push notifications.
* During registration, this is used to receive push challenge tokens from the server.
*/
class FcmReceiveService : FirebaseMessagingService() {
companion object {
private val TAG = Log.tag(FcmReceiveService::class)
}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
Log.d(TAG, "onMessageReceived: ${remoteMessage.messageId}")
val challenge = remoteMessage.data["challenge"]
if (challenge != null) {
Log.d(TAG, "Received push challenge")
PushChallengeReceiver.onChallengeReceived(challenge)
}
}
override fun onNewToken(token: String) {
Log.d(TAG, "onNewToken")
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.fcm
import android.content.Context
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.firebase.FirebaseApp
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.tasks.await
import org.signal.core.util.logging.Log
/**
* Utility functions for Firebase Cloud Messaging.
*/
object FcmUtil {
private val TAG = Log.tag(FcmUtil::class)
/**
* Retrieves the FCM registration token if available.
* Returns null if FCM is not available on this device.
*
* @param context Application context needed to initialize Firebase
*/
suspend fun getToken(context: Context): String? {
return try {
FirebaseApp.initializeApp(context)
val token = FirebaseMessaging.getInstance().token.await()
Log.d(TAG, "FCM token retrieved successfully")
token
} catch (e: Exception) {
Log.w(TAG, "Failed to get FCM token", e)
null
}
}
/**
* Checks if Google Play Services is available on this device.
*/
fun isPlayServicesAvailable(context: Context): Boolean {
val availability = GoogleApiAvailability.getInstance()
val resultCode = availability.isGooglePlayServicesAvailable(context)
return resultCode == ConnectionResult.SUCCESS
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.fcm
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import org.signal.core.util.logging.Log
/**
* Singleton that receives push challenge tokens from FCM and makes them
* available to the registration flow.
*/
object PushChallengeReceiver {
private val TAG = Log.tag(PushChallengeReceiver::class)
private val challengeFlow = MutableSharedFlow<String>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
/**
* Called by FcmReceiveService when a push challenge is received.
*/
fun onChallengeReceived(challenge: String) {
Log.d(TAG, "Push challenge received")
challengeFlow.tryEmit(challenge)
}
/**
* Suspends until a push challenge token is received.
* The caller should wrap this in withTimeoutOrNull to handle timeout.
*/
suspend fun awaitChallenge(): String {
Log.d(TAG, "Waiting for push challenge...")
return challengeFlow.first()
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.screens
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Previews
@Composable
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")
}
}
}
@Preview(showBackground = true)
@Composable
private fun RegistrationCompleteScreenPreview() {
Previews.Preview {
RegistrationCompleteScreen(onStartOver = {})
}
}

View File

@@ -0,0 +1,217 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.screens.main
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.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Previews
@Composable
fun MainScreen(
state: MainScreenState,
onEvent: (MainScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
var showClearDataDialog by remember { mutableStateOf(false) }
if (showClearDataDialog) {
AlertDialog(
onDismissRequest = { showClearDataDialog = false },
title = { Text("Clear All Data?") },
text = { Text("This will delete all registration data including your account information, keys, and PIN. This cannot be undone.") },
confirmButton = {
TextButton(
onClick = {
showClearDataDialog = false
onEvent(MainScreenEvents.ClearAllData)
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Clear")
}
},
dismissButton = {
TextButton(onClick = { showClearDataDialog = false }) {
Text("Cancel")
}
}
)
}
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Spacer(modifier = Modifier.height(32.dp))
if (state.existingRegistrationState == null) {
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)
)
Spacer(modifier = Modifier.height(32.dp))
}
if (state.existingRegistrationState != null) {
RegistrationInfo(state.existingRegistrationState)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { onEvent(MainScreenEvents.LaunchRegistration) },
modifier = Modifier.fillMaxWidth()
) {
Text("Re-register")
}
OutlinedButton(
onClick = { onEvent(MainScreenEvents.OpenPinSettings) },
modifier = Modifier.fillMaxWidth()
) {
Text("PIN & Registration Lock Settings")
}
TextButton(
onClick = { showClearDataDialog = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Clear All Data")
}
} else {
Button(
onClick = { onEvent(MainScreenEvents.LaunchRegistration) },
modifier = Modifier.fillMaxWidth()
) {
Text("Start Registration")
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
@Composable
private fun RegistrationInfo(data: MainScreenState.ExistingRegistrationState) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Registered Account",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
RegistrationField(label = "Phone Number", value = data.phoneNumber)
RegistrationField(label = "ACI", value = data.aci)
RegistrationField(label = "PNI", value = data.pni)
RegistrationField(label = "AEP", value = data.aep)
RegistrationField(label = "Temporary Master Key", value = data.temporaryMasterKey ?: "null")
if (data.pinsOptedOut) {
RegistrationField(label = "PINs Opted Out", value = "Yes")
} else {
RegistrationField(label = "PIN", value = data.pin ?: "(not set)")
RegistrationField(label = "Registration Lock", value = if (data.registrationLockEnabled) "Enabled" else "Disabled")
}
}
}
}
@Composable
private fun RegistrationField(label: String, value: String) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurface
)
}
}
@Preview(showBackground = true)
@Composable
private fun MainScreenPreview() {
Previews.Preview {
MainScreen(
state = MainScreenState(),
onEvent = {}
)
}
}
@Preview(showBackground = true)
@Composable
private fun MainScreenWithRegistrationPreview() {
Previews.Preview {
MainScreen(
state = MainScreenState(
existingRegistrationState = MainScreenState.ExistingRegistrationState(
phoneNumber = "+15551234567",
aci = "12345678-1234-1234-1234-123456789abc",
pni = "abcdefab-abcd-abcd-abcd-abcdefabcdef",
aep = "aep1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd",
pin = "1234",
registrationLockEnabled = true,
pinsOptedOut = false,
temporaryMasterKey = null
)
),
onEvent = {}
)
}
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.screens.main
sealed interface MainScreenEvents {
data object LaunchRegistration : MainScreenEvents
data object OpenPinSettings : MainScreenEvents
data object ClearAllData : MainScreenEvents
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.screens.main
data class MainScreenState(
val existingRegistrationState: ExistingRegistrationState? = null
) {
data class ExistingRegistrationState(
val phoneNumber: String,
val aci: String,
val pni: String,
val aep: String,
val pin: String?,
val registrationLockEnabled: Boolean,
val pinsOptedOut: Boolean,
val temporaryMasterKey: String?
)
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.screens.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.signal.core.util.Base64
import org.signal.registration.StorageController
import org.signal.registration.sample.storage.RegistrationPreferences
class MainScreenViewModel(
private val storageController: StorageController,
private val onLaunchRegistration: () -> Unit,
private val onOpenPinSettings: () -> Unit
) : ViewModel() {
private val _state = MutableStateFlow(MainScreenState())
val state: StateFlow<MainScreenState> = _state.asStateFlow()
init {
loadRegistrationData()
}
fun refreshData() {
loadRegistrationData()
}
fun onEvent(event: MainScreenEvents) {
viewModelScope.launch {
when (event) {
MainScreenEvents.LaunchRegistration -> onLaunchRegistration()
MainScreenEvents.OpenPinSettings -> onOpenPinSettings()
MainScreenEvents.ClearAllData -> {
storageController.clearAllData()
refreshData()
}
}
}
}
private fun loadRegistrationData() {
viewModelScope.launch {
val existingData = storageController.getPreExistingRegistrationData()
_state.value = _state.value.copy(
existingRegistrationState = if (existingData != null) {
MainScreenState.ExistingRegistrationState(
phoneNumber = existingData.e164,
aci = existingData.aci.toString(),
pni = existingData.pni.toStringWithoutPrefix(),
aep = existingData.aep.value,
pin = RegistrationPreferences.pin,
registrationLockEnabled = RegistrationPreferences.registrationLockEnabled,
pinsOptedOut = RegistrationPreferences.pinsOptedOut,
temporaryMasterKey = RegistrationPreferences.temporaryMasterKey?.let {
Base64.encodeWithPadding(it.serialize())
}
)
} else {
null
}
)
}
}
class Factory(
private val storageController: StorageController,
private val onLaunchRegistration: () -> Unit,
private val onOpenPinSettings: () -> Unit
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return MainScreenViewModel(storageController, onLaunchRegistration, onOpenPinSettings) as T
}
}
}

View File

@@ -0,0 +1,14 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.screens.pinsettings
sealed interface PinSettingsEvents {
data class SetPin(val pin: String) : PinSettingsEvents
data object ToggleRegistrationLock : PinSettingsEvents
data object TogglePinsOptOut : PinSettingsEvents
data object Back : PinSettingsEvents
data object DismissMessage : PinSettingsEvents
}

View File

@@ -0,0 +1,337 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.screens.pinsettings
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.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
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.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PinSettingsScreen(
state: PinSettingsState,
onEvent: (PinSettingsEvents) -> Unit,
modifier: Modifier = Modifier
) {
var pinInput by remember { mutableStateOf("") }
Scaffold(
topBar = {
TopAppBar(
title = { Text("PIN Settings") },
navigationIcon = {
TextButton(onClick = { onEvent(PinSettingsEvents.Back) }) {
Text("Back")
}
}
)
},
snackbarHost = {
if (state.toastMessage != null) {
Snackbar(
action = {
TextButton(onClick = { onEvent(PinSettingsEvents.DismissMessage) }) {
Text("Dismiss")
}
}
) {
Text(state.toastMessage)
}
}
},
modifier = modifier
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// PIN Setup Section
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Set Your PIN",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Your PIN protects your account and allows you to restore your data if you need to re-register.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = pinInput,
onValueChange = { if (it.length <= 6) pinInput = it },
label = { Text("Enter PIN (4-6 digits)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.NumberPassword,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
if (pinInput.length >= 4 && !state.pinsOptedOut) {
onEvent(PinSettingsEvents.SetPin(pinInput))
}
}
),
enabled = !state.loading && !state.pinsOptedOut
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { onEvent(PinSettingsEvents.SetPin(pinInput)) },
modifier = Modifier.fillMaxWidth(),
enabled = pinInput.length >= 4 && !state.loading && !state.pinsOptedOut
) {
Text(if (state.hasPinSet) "Update PIN" else "Set PIN")
}
if (state.hasPinSet && !state.pinsOptedOut) {
Text(
text = "PIN is currently set",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp)
)
}
if (state.pinsOptedOut) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = "Opt back into PINs to set a PIN",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Registration Lock Section
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Registration Lock",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "When enabled, your PIN will be required when re-registering your phone number.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = state.registrationLockEnabled,
onCheckedChange = { onEvent(PinSettingsEvents.ToggleRegistrationLock) },
enabled = state.hasPinSet && !state.loading && !state.pinsOptedOut
)
}
if (!state.hasPinSet && !state.pinsOptedOut) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = "Set a PIN first to enable registration lock",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
if (state.pinsOptedOut) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = "Opt back into PINs to enable registration lock",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// PIN Opt-Out Section
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Opt Out of PINs",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Disables PIN-based account recovery. Your data will not be backed up to the server.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = state.pinsOptedOut,
onCheckedChange = { onEvent(PinSettingsEvents.TogglePinsOptOut) },
enabled = !state.registrationLockEnabled && !state.loading
)
}
if (state.registrationLockEnabled) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = "Disable registration lock first to opt out of PINs",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
if (state.loading) {
Dialogs.IndeterminateProgressDialog()
}
}
}
}
@DayNightPreviews
@Composable
private fun PinSettingsScreenPreview() {
Previews.Preview {
PinSettingsScreen(
state = PinSettingsState(),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun PinSettingsScreenWithPinPreview() {
Previews.Preview {
PinSettingsScreen(
state = PinSettingsState(
hasPinSet = true,
registrationLockEnabled = true
),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun PinSettingsScreenLoadingPreview() {
Previews.Preview {
PinSettingsScreen(
state = PinSettingsState(
loading = true
),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun PinSettingsScreenOptedOutPreview() {
Previews.Preview {
PinSettingsScreen(
state = PinSettingsState(
pinsOptedOut = true
),
onEvent = {}
)
}
}

View File

@@ -0,0 +1,14 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.screens.pinsettings
data class PinSettingsState(
val hasPinSet: Boolean = false,
val registrationLockEnabled: Boolean = false,
val pinsOptedOut: Boolean = false,
val loading: Boolean = false,
val toastMessage: String? = null
)

View File

@@ -0,0 +1,232 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.screens.pinsettings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.signal.core.util.logging.Log
import org.signal.registration.NetworkController
import org.signal.registration.NetworkController.RegistrationNetworkResult
import org.signal.registration.sample.storage.RegistrationPreferences
/**
* ViewModel for the PIN settings screen.
*
* Handles setting PIN via SVR backup and enabling/disabling registration lock.
*/
class PinSettingsViewModel(
private val networkController: NetworkController,
private val onBack: () -> Unit
) : ViewModel() {
companion object {
private val TAG = Log.tag(PinSettingsViewModel::class)
}
private val _state = MutableStateFlow(
PinSettingsState(
hasPinSet = RegistrationPreferences.hasPin,
registrationLockEnabled = RegistrationPreferences.registrationLockEnabled,
pinsOptedOut = RegistrationPreferences.pinsOptedOut
)
)
val state: StateFlow<PinSettingsState> = _state.asStateFlow()
fun onEvent(event: PinSettingsEvents) {
when (event) {
is PinSettingsEvents.SetPin -> {
_state.value = _state.value.copy(loading = true)
handleSetPin(event.pin)
_state.value = _state.value.copy(loading = true)
}
is PinSettingsEvents.ToggleRegistrationLock -> {
_state.value = _state.value.copy(loading = true)
handleToggleRegistrationLock()
_state.value = _state.value.copy(loading = false)
}
is PinSettingsEvents.TogglePinsOptOut -> {
_state.value = _state.value.copy(loading = true)
handleTogglePinsOptOut()
}
is PinSettingsEvents.Back -> onBack()
is PinSettingsEvents.DismissMessage -> dismissMessage()
}
}
private fun handleSetPin(pin: String) {
if (pin.length < 4) {
_state.value = _state.value.copy(toastMessage = "PIN must be at least 4 digits")
return
}
viewModelScope.launch {
// Generate or reuse existing master key
val masterKey = RegistrationPreferences.masterKey ?: run {
_state.value = _state.value.copy(toastMessage = "No master key found!")
return@launch
}
when (val result = networkController.setPinAndMasterKeyOnSvr(pin, masterKey)) {
is RegistrationNetworkResult.Success -> {
Log.i(TAG, "Successfully backed up PIN to SVR")
RegistrationPreferences.pin = pin
_state.value = _state.value.copy(
loading = false,
hasPinSet = true,
toastMessage = "PIN has been set successfully"
)
}
is RegistrationNetworkResult.Failure -> {
Log.w(TAG, "Failed to backup PIN: ${result.error}")
_state.value = _state.value.copy(
loading = false,
toastMessage = "Failed to set PIN: ${result.error::class.simpleName}"
)
}
is RegistrationNetworkResult.NetworkError -> {
Log.w(TAG, "Network error while setting PIN", result.exception)
_state.value = _state.value.copy(
loading = false,
toastMessage = "Network error. Please check your connection."
)
}
is RegistrationNetworkResult.ApplicationError -> {
Log.w(TAG, "Application error while setting PIN", result.exception)
_state.value = _state.value.copy(
loading = false,
toastMessage = "An error occurred: ${result.exception.message}"
)
}
}
}
}
private fun handleToggleRegistrationLock() {
val currentlyEnabled = _state.value.registrationLockEnabled
viewModelScope.launch {
val result = if (currentlyEnabled) {
networkController.disableRegistrationLock()
} else {
networkController.enableRegistrationLock()
}
when (result) {
is RegistrationNetworkResult.Success -> {
val newEnabled = !currentlyEnabled
RegistrationPreferences.registrationLockEnabled = newEnabled
Log.i(TAG, "Registration lock ${if (newEnabled) "enabled" else "disabled"}")
_state.value = _state.value.copy(
loading = false,
registrationLockEnabled = newEnabled,
toastMessage = if (newEnabled) "Registration lock enabled" else "Registration lock disabled"
)
}
is RegistrationNetworkResult.Failure -> {
Log.w(TAG, "Failed to toggle registration lock: ${result.error}")
_state.value = _state.value.copy(
loading = false,
toastMessage = "Failed to update registration lock"
)
}
is RegistrationNetworkResult.NetworkError -> {
Log.w(TAG, "Network error while toggling registration lock", result.exception)
_state.value = _state.value.copy(
loading = false,
toastMessage = "Network error. Please check your connection."
)
}
is RegistrationNetworkResult.ApplicationError -> {
Log.w(TAG, "Application error while toggling registration lock", result.exception)
_state.value = _state.value.copy(
loading = false,
toastMessage = "An error occurred: ${result.exception.message}"
)
}
}
}
}
private fun handleTogglePinsOptOut() {
val currentlyOptedOut = _state.value.pinsOptedOut
val newOptedOut = !currentlyOptedOut
viewModelScope.launch {
val attributes = NetworkController.AccountAttributes(
signalingKey = null,
registrationId = RegistrationPreferences.aciRegistrationId,
voice = true,
video = true,
fetchesMessages = true,
registrationLock = null,
unidentifiedAccessKey = null,
unrestrictedUnidentifiedAccess = false,
discoverableByPhoneNumber = false,
capabilities = NetworkController.AccountAttributes.Capabilities(
storage = !newOptedOut,
versionedExpirationTimer = true,
attachmentBackfill = true,
spqr = true
),
name = null,
pniRegistrationId = RegistrationPreferences.pniRegistrationId,
recoveryPassword = null
)
when (val result = networkController.setAccountAttributes(attributes)) {
is RegistrationNetworkResult.Success -> {
RegistrationPreferences.pinsOptedOut = newOptedOut
Log.i(TAG, "PINs opt-out ${if (newOptedOut) "enabled" else "disabled"}")
_state.value = _state.value.copy(
loading = false,
pinsOptedOut = newOptedOut,
toastMessage = if (newOptedOut) "Opted out of PINs" else "Opted back into PINs"
)
}
is RegistrationNetworkResult.Failure -> {
Log.w(TAG, "Failed to toggle PINs opt-out: ${result.error}")
_state.value = _state.value.copy(
loading = false,
toastMessage = "Failed to update PIN settings"
)
}
is RegistrationNetworkResult.NetworkError -> {
Log.w(TAG, "Network error while toggling PINs opt-out", result.exception)
_state.value = _state.value.copy(
loading = false,
toastMessage = "Network error. Please check your connection."
)
}
is RegistrationNetworkResult.ApplicationError -> {
Log.w(TAG, "Application error while toggling PINs opt-out", result.exception)
_state.value = _state.value.copy(
loading = false,
toastMessage = "An error occurred: ${result.exception.message}"
)
}
}
}
}
private fun dismissMessage() {
_state.value = _state.value.copy(toastMessage = null)
}
class Factory(
private val networkController: NetworkController,
private val onBack: () -> Unit
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return PinSettingsViewModel(networkController, onBack) as T
}
}
}

View File

@@ -0,0 +1,132 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.storage
import android.content.Context
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.SupportSQLiteOpenHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import org.signal.core.util.deleteAll
import org.signal.core.util.insertInto
import org.signal.core.util.withinTransaction
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
/**
* SQLite database for storing prekey data in the sample app.
* Only stores signed prekeys and kyber prekeys, which benefit from
* database storage due to their structure.
*/
class RegistrationDatabase(context: Context) {
companion object {
private const val DATABASE_NAME = "registration.db"
private const val DATABASE_VERSION = 2
const val ACCOUNT_TYPE_ACI = "aci"
const val ACCOUNT_TYPE_PNI = "pni"
}
private val openHelper: SupportSQLiteOpenHelper = FrameworkSQLiteOpenHelperFactory().create(
SupportSQLiteOpenHelper.Configuration(
context = context,
name = DATABASE_NAME,
callback = Callback()
)
)
val writableDatabase: SupportSQLiteDatabase get() = openHelper.writableDatabase
val readableDatabase: SupportSQLiteDatabase get() = openHelper.readableDatabase
val signedPreKeys = SampleSignedPreKeyTable(this)
val kyberPreKeys = SampleKyberPreKeyTable(this)
fun clearAllPreKeys() {
writableDatabase.withinTransaction { db ->
db.deleteAll(SampleSignedPreKeyTable.TABLE_NAME)
db.deleteAll(SampleKyberPreKeyTable.TABLE_NAME)
}
}
private class Callback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
override fun onCreate(db: SupportSQLiteDatabase) {
db.execSQL(SampleSignedPreKeyTable.CREATE_TABLE)
db.execSQL(SampleKyberPreKeyTable.CREATE_TABLE)
}
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit
}
/**
* Table for storing signed pre-keys.
*/
class SampleSignedPreKeyTable(private val db: RegistrationDatabase) {
companion object {
const val TABLE_NAME = "signed_prekeys"
private const val ID = "_id"
private const val ACCOUNT_TYPE = "account_type"
private const val KEY_ID = "key_id"
private const val KEY_DATA = "key_data"
const val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
$ACCOUNT_TYPE TEXT NOT NULL,
$KEY_ID INTEGER NOT NULL,
$KEY_DATA BLOB NOT NULL
)
"""
}
fun insert(accountType: String, signedPreKey: SignedPreKeyRecord) {
db.writableDatabase
.insertInto(TABLE_NAME)
.values(
ACCOUNT_TYPE to accountType,
KEY_ID to signedPreKey.id,
KEY_DATA to signedPreKey.serialize()
)
.run()
}
}
/**
* Table for storing Kyber pre-keys.
*/
class SampleKyberPreKeyTable(private val db: RegistrationDatabase) {
companion object {
const val TABLE_NAME = "kyber_prekeys"
private const val ID = "_id"
private const val ACCOUNT_TYPE = "account_type"
private const val KEY_ID = "key_id"
private const val KEY_DATA = "key_data"
const val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
$ACCOUNT_TYPE TEXT NOT NULL,
$KEY_ID INTEGER NOT NULL,
$KEY_DATA BLOB NOT NULL
)
"""
}
fun insert(accountType: String, kyberPreKey: KyberPreKeyRecord) {
db.writableDatabase
.insertInto(TABLE_NAME)
.values(
ACCOUNT_TYPE to accountType,
KEY_ID to kyberPreKey.id,
KEY_DATA to kyberPreKey.serialize()
)
.run()
}
}
}

View File

@@ -0,0 +1,161 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.storage
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.core.util.Base64
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.signal.registration.NewRegistrationData
import org.signal.registration.PreExistingRegistrationData
/**
* SharedPreferences-based storage for registration data that doesn't need
* the complexity of a SQLite database.
*/
object RegistrationPreferences {
private lateinit var context: Application
private const val PREFS_NAME = "registration_prefs"
private const val KEY_E164 = "e164"
private const val KEY_ACI = "aci"
private const val KEY_PNI = "pni"
private const val KEY_SERVICE_PASSWORD = "service_password"
private const val KEY_AEP = "aep"
private const val KEY_PROFILE_KEY = "profile_key"
private const val KEY_ACI_REGISTRATION_ID = "aci_registration_id"
private const val KEY_PNI_REGISTRATION_ID = "pni_registration_id"
private const val KEY_ACI_IDENTITY_KEY = "aci_identity_key"
private const val KEY_PNI_IDENTITY_KEY = "pni_identity_key"
private const val KEY_TEMPORARY_MASTER_KEY = "temporary_master_key"
private const val KEY_REGISTRATION_LOCK_ENABLED = "registration_lock_enabled"
private const val KEY_PIN = "has_pin"
private const val KEY_PIN_ALPHANUMERIC = "pin_alphanumeric"
private const val KEY_PINS_OPTED_OUT = "pins_opted_out"
fun init(context: Application) {
this.context = context
}
private val prefs: SharedPreferences by lazy {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
var e164: String?
get() = prefs.getString(KEY_E164, null)
set(value) = prefs.edit { putString(KEY_E164, value) }
var aci: ACI?
get() = prefs.getString(KEY_ACI, null)?.let { ACI.parseOrNull(it) }
set(value) = prefs.edit { putString(KEY_ACI, value?.toString()) }
var pni: PNI?
get() = prefs.getString(KEY_PNI, null)?.let { PNI.parseOrNull(it) }
set(value) = prefs.edit { putString(KEY_PNI, value?.toString()) }
var servicePassword: String?
get() = prefs.getString(KEY_SERVICE_PASSWORD, null)
set(value) = prefs.edit { putString(KEY_SERVICE_PASSWORD, value) }
var aep: AccountEntropyPool?
get() = prefs.getString(KEY_AEP, null)?.let { AccountEntropyPool(it) }
set(value) = prefs.edit { putString(KEY_AEP, value?.toString()) }
var profileKey: ProfileKey?
get() = prefs.getString(KEY_PROFILE_KEY, null)?.let { ProfileKey(Base64.decode(it)) }
set(value) = prefs.edit { putString(KEY_PROFILE_KEY, value?.let { Base64.encodeWithPadding(it.serialize()) }) }
var aciRegistrationId: Int
get() = prefs.getInt(KEY_ACI_REGISTRATION_ID, -1)
set(value) = prefs.edit { putInt(KEY_ACI_REGISTRATION_ID, value) }
var pniRegistrationId: Int
get() = prefs.getInt(KEY_PNI_REGISTRATION_ID, -1)
set(value) = prefs.edit { putInt(KEY_PNI_REGISTRATION_ID, value) }
var aciIdentityKeyPair: IdentityKeyPair?
get() = prefs.getString(KEY_ACI_IDENTITY_KEY, null)?.let { IdentityKeyPair(Base64.decode(it)) }
set(value) = prefs.edit { putString(KEY_ACI_IDENTITY_KEY, value?.let { Base64.encodeWithPadding(it.serialize()) }) }
var pniIdentityKeyPair: IdentityKeyPair?
get() = prefs.getString(KEY_PNI_IDENTITY_KEY, null)?.let { IdentityKeyPair(Base64.decode(it)) }
set(value) = prefs.edit { putString(KEY_PNI_IDENTITY_KEY, value?.let { Base64.encodeWithPadding(it.serialize()) }) }
val masterKey: MasterKey?
get() = aep?.deriveMasterKey()
var temporaryMasterKey: MasterKey?
get() = prefs.getString(KEY_TEMPORARY_MASTER_KEY, null)?.let { MasterKey(Base64.decode(it)) }
set(value) = prefs.edit { putString(KEY_TEMPORARY_MASTER_KEY, value?.let { Base64.encodeWithPadding(it.serialize()) }) }
var registrationLockEnabled: Boolean
get() = prefs.getBoolean(KEY_REGISTRATION_LOCK_ENABLED, false)
set(value) = prefs.edit { putBoolean(KEY_REGISTRATION_LOCK_ENABLED, value) }
val hasPin: Boolean
get() = pin != null
var pin: String?
get() = prefs.getString(KEY_PIN, null)
set(value) = prefs.edit { putString(KEY_PIN, value) }
var pinAlphanumeric: Boolean
get() = prefs.getBoolean(KEY_PIN_ALPHANUMERIC, false)
set(value) = prefs.edit { putBoolean(KEY_PIN_ALPHANUMERIC, value) }
var pinsOptedOut: Boolean
get() = prefs.getBoolean(KEY_PINS_OPTED_OUT, false)
set(value) = prefs.edit { putBoolean(KEY_PINS_OPTED_OUT, value) }
fun saveRegistrationData(data: NewRegistrationData) {
prefs.edit {
putString(KEY_E164, data.e164)
putString(KEY_ACI, data.aci.toString())
putString(KEY_PNI, data.pni.toString())
putString(KEY_SERVICE_PASSWORD, data.servicePassword)
putString(KEY_AEP, data.aep.value)
}
}
fun getPreExistingRegistrationData(): PreExistingRegistrationData? {
val e164 = e164 ?: return null
val aci = aci ?: return null
val pni = pni ?: return null
val servicePassword = servicePassword ?: return null
val aep = aep ?: return null
return PreExistingRegistrationData(
e164 = e164,
aci = aci,
pni = pni,
servicePassword = servicePassword,
aep = aep
)
}
fun clearKeyMaterial() {
prefs.edit {
remove(KEY_PROFILE_KEY)
remove(KEY_ACI_REGISTRATION_ID)
remove(KEY_PNI_REGISTRATION_ID)
remove(KEY_ACI_IDENTITY_KEY)
remove(KEY_PNI_IDENTITY_KEY)
}
}
fun clearAll() {
prefs.edit { clear() }
}
}

View File

@@ -0,0 +1,145 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.util
import org.signal.core.models.MasterKey
import org.signal.registration.KeyMaterial
import org.signal.registration.NetworkController
import org.signal.registration.NewRegistrationData
import org.signal.registration.PreExistingRegistrationData
import org.signal.registration.RegistrationDependencies
import org.signal.registration.StorageController
import java.util.Locale
object PreviewRegistrationDependencies {
fun get(): RegistrationDependencies {
return RegistrationDependencies(
networkController = PreviewNewtorkController(),
storageController = PreviewStorageController()
)
}
}
private class PreviewNewtorkController : NetworkController {
override suspend fun createSession(
e164: String,
fcmToken: String?,
mcc: String?,
mnc: String?
): NetworkController.RegistrationNetworkResult<NetworkController.SessionMetadata, NetworkController.CreateSessionError> {
TODO("Not yet implemented")
}
override suspend fun getSession(sessionId: String): NetworkController.RegistrationNetworkResult<NetworkController.SessionMetadata, NetworkController.GetSessionStatusError> {
TODO("Not yet implemented")
}
override suspend fun updateSession(
sessionId: String?,
pushChallengeToken: String?,
captchaToken: String?
): NetworkController.RegistrationNetworkResult<NetworkController.SessionMetadata, NetworkController.UpdateSessionError> {
TODO("Not yet implemented")
}
override suspend fun requestVerificationCode(
sessionId: String,
locale: Locale?,
androidSmsRetrieverSupported: Boolean,
transport: NetworkController.VerificationCodeTransport
): NetworkController.RegistrationNetworkResult<NetworkController.SessionMetadata, NetworkController.RequestVerificationCodeError> {
TODO("Not yet implemented")
}
override suspend fun submitVerificationCode(
sessionId: String,
verificationCode: String
): NetworkController.RegistrationNetworkResult<NetworkController.SessionMetadata, NetworkController.SubmitVerificationCodeError> {
TODO("Not yet implemented")
}
override suspend fun registerAccount(
e164: String,
password: String,
sessionId: String?,
recoveryPassword: String?,
attributes: NetworkController.AccountAttributes,
aciPreKeys: NetworkController.PreKeyCollection,
pniPreKeys: NetworkController.PreKeyCollection,
fcmToken: String?,
skipDeviceTransfer: Boolean
): NetworkController.RegistrationNetworkResult<NetworkController.RegisterAccountResponse, NetworkController.RegisterAccountError> {
TODO("Not yet implemented")
}
override suspend fun getFcmToken(): String? {
TODO("Not yet implemented")
}
override suspend fun awaitPushChallengeToken(): String? {
TODO("Not yet implemented")
}
override fun getCaptchaUrl(): String {
TODO("Not yet implemented")
}
override suspend fun restoreMasterKeyFromSvr(
svr2Credentials: NetworkController.SvrCredentials,
pin: String
): NetworkController.RegistrationNetworkResult<NetworkController.MasterKeyResponse, NetworkController.RestoreMasterKeyError> {
TODO("Not yet implemented")
}
override suspend fun setPinAndMasterKeyOnSvr(
pin: String,
masterKey: MasterKey
): NetworkController.RegistrationNetworkResult<Unit, NetworkController.BackupMasterKeyError> {
TODO("Not yet implemented")
}
override suspend fun enableRegistrationLock(): NetworkController.RegistrationNetworkResult<Unit, NetworkController.SetRegistrationLockError> {
TODO("Not yet implemented")
}
override suspend fun disableRegistrationLock(): NetworkController.RegistrationNetworkResult<Unit, NetworkController.SetRegistrationLockError> {
TODO("Not yet implemented")
}
override suspend fun getSvrCredentials(): NetworkController.RegistrationNetworkResult<NetworkController.SvrCredentials, NetworkController.GetSvrCredentialsError> {
TODO("Not yet implemented")
}
override suspend fun setAccountAttributes(attributes: NetworkController.AccountAttributes): NetworkController.RegistrationNetworkResult<Unit, NetworkController.SetAccountAttributesError> {
TODO("Not yet implemented")
}
}
private class PreviewStorageController : StorageController {
override suspend fun generateAndStoreKeyMaterial(): KeyMaterial {
TODO("Not yet implemented")
}
override suspend fun saveNewRegistrationData(newRegistrationData: NewRegistrationData) {
TODO("Not yet implemented")
}
override suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? {
TODO("Not yet implemented")
}
override suspend fun saveValidatedPinAndTemporaryMasterKey(pin: String, isAlphanumeric: Boolean, masterKey: MasterKey, registrationLockEnabled: Boolean) {
TODO("Not yet implemented")
}
override suspend fun saveNewlyCreatedPin(pin: String, isAlphanumeric: Boolean) {
TODO("Not yet implemented")
}
override suspend fun clearAllData() {
TODO("Not yet implemented")
}
}

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="258"
android:viewportHeight="190.08008">
<group android:scaleX="0.46"
android:scaleY="0.33890247"
android:translateX="69.66"
android:translateY="62.830734">
<group android:translateY="146.88005">
<path android:pathData="M13.390625,-0L13.390625,-105.125L45.796875,-105.125Q55.15625,-105.125,62.203125,-101.296875Q69.265625,-97.484375,73.15625,-90.71875Q77.046875,-83.953125,77.046875,-74.875Q77.046875,-64.21875,71.5625,-56.578125Q66.09375,-48.953125,57.03125,-45.9375L78.484375,-0L64.21875,-0L43.625,-44.640625L26.203125,-44.640625L26.203125,-0L13.390625,-0ZM26.203125,-56.296875L45.796875,-56.296875Q53.859375,-56.296875,58.75,-61.40625Q63.640625,-66.53125,63.640625,-74.875Q63.640625,-83.375,58.75,-88.40625Q53.859375,-93.453125,45.796875,-93.453125L26.203125,-93.453125L26.203125,-56.296875Z"
android:fillColor="#FFFFFF"/>
<path android:pathData="M100.40625,0L100.40625,-105.125L160.875,-105.125L160.875,-93.3125L113.21875,-93.3125L113.21875,-60.765625L155.84375,-60.765625L155.84375,-49.25L113.21875,-49.25L113.21875,-11.8125L160.875,-11.8125L160.875,0L100.40625,0Z"
android:fillColor="#FFFFFF"/>
<path android:pathData="M215.78125,1.4375Q201.375,1.4375,193.01562,-6.625Q184.67188,-14.6875,184.67188,-28.796875L184.67188,-76.3125Q184.67188,-90.4375,193.01562,-98.5Q201.375,-106.5625,215.78125,-106.5625Q229.89062,-106.5625,238.23438,-98.421875Q246.59375,-90.28125,246.59375,-76.3125L233.625,-76.3125Q233.625,-85.25,228.9375,-90.0625Q224.26562,-94.890625,215.78125,-94.890625Q207.28125,-94.890625,202.45312,-90.140625Q197.625,-85.390625,197.625,-76.46875L197.625,-28.796875Q197.625,-19.875,202.45312,-14.96875Q207.28125,-10.078125,215.78125,-10.078125Q224.26562,-10.078125,228.9375,-14.96875Q233.625,-19.875,233.625,-28.796875L233.625,-43.203125L212.3125,-43.203125L212.3125,-55.015625L246.59375,-55.015625L246.59375,-28.796875Q246.59375,-14.828125,238.23438,-6.6875Q229.89062,1.4375,215.78125,1.4375Z"
android:fillColor="#FFFFFF"/>
</group>
</group>
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="google_app_id" translatable="false">1:312334754206:android:a9297b152879f266</string>
<string name="gcm_defaultSenderId" translatable="false">312334754206</string>
<string name="default_web_client_id" translatable="false">312334754206-dg1p1mtekis8ivja3ica50vonmrlunh4.apps.googleusercontent.com</string>
<string name="firebase_database_url" translatable="false">https://api-project-312334754206.firebaseio.com</string>
<string name="google_api_key" translatable="false">AIzaSyDrfzNAPBPzX6key51hqo3p5LZXF5Y-yxU</string>
<string name="google_crash_reporting_api_key" translatable="false">AIzaSyDrfzNAPBPzX6key51hqo3p5LZXF5Y-yxU</string>
<string name="project_id" translatable="false">api-project-312334754206</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
</resources>