Add basic reglock support to regV5.

This commit is contained in:
Greyson Parrelli
2025-12-08 09:11:14 -05:00
committed by Michelle Tang
parent 4b06e14df6
commit 7969df4e4c
33 changed files with 1961 additions and 329 deletions

View File

@@ -6,6 +6,7 @@
package org.signal.core.models
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.util.logging.Log
private typealias LibSignalAccountEntropyPool = org.signal.libsignal.messagebackup.AccountEntropyPool

View File

@@ -69,6 +69,8 @@ dependencies {
// 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)

View File

@@ -10,12 +10,25 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.viewModels
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.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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
@@ -30,11 +43,44 @@ import kotlinx.serialization.Serializable
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.navigation.ResultEffect
import org.signal.core.ui.navigation.ResultEventBus
import org.signal.registration.NetworkController
import org.signal.registration.RegistrationActivity
import org.signal.registration.RegistrationDependencies
import org.signal.registration.StorageController
import org.signal.registration.sample.MainActivity.Companion.REGISTRATION_RESULT
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
/**
* 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))
)
}
/**
* Navigation routes for the sample app.
@@ -45,6 +91,9 @@ sealed interface SampleRoute : NavKey {
@Serializable
data object RegistrationComplete : SampleRoute
@Serializable
data object PinSettings : SampleRoute
}
/**
@@ -76,6 +125,8 @@ class MainActivity : ComponentActivity() {
onLaunchRegistration = { registrationLauncher.launch(Unit) },
backStack = backStack,
resultEventBus = viewModel.resultEventBus,
storageController = RegistrationDependencies.get().storageController,
networkController = RegistrationDependencies.get().networkController,
onStartOver = {
backStack.clear()
backStack.add(SampleRoute.Main)
@@ -93,17 +144,29 @@ private fun SampleNavHost(
onStartOver: () -> Unit,
backStack: NavBackStack<NavKey>,
resultEventBus: ResultEventBus,
storageController: StorageController,
networkController: NetworkController,
modifier: Modifier = Modifier
) {
val entryProvider: (NavKey) -> NavEntry<NavKey> = entryProvider {
entry<SampleRoute.Main> {
val viewModel: MainScreenViewModel = viewModel(
factory = MainScreenViewModel.Factory(onLaunchRegistration)
factory = MainScreenViewModel.Factory(
storageController = storageController,
onLaunchRegistration = onLaunchRegistration,
onOpenPinSettings = { backStack.add(SampleRoute.PinSettings) }
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
LifecycleResumeEffect(Unit) {
viewModel.refreshData()
onPauseOrDispose { }
}
ResultEffect<Boolean>(resultEventBus, REGISTRATION_RESULT) { success ->
if (success) {
viewModel.refreshData()
backStack.add(SampleRoute.RegistrationComplete)
}
}
@@ -117,6 +180,23 @@ private fun SampleNavHost(
entry<SampleRoute.RegistrationComplete> {
RegistrationCompleteScreen(onStartOver = onStartOver)
}
entry<SampleRoute.PinSettings>(
metadata = BottomSheetTransitionSpec
) {
val viewModel: PinSettingsViewModel = viewModel(
factory = PinSettingsViewModel.Factory(
networkController = networkController,
onBack = { backStack.removeLastOrNull() }
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
PinSettingsScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
)
}
}
val decorators = listOf(
@@ -131,7 +211,51 @@ private fun SampleNavHost(
NavDisplay(
entries = entries,
onBack = {},
modifier = modifier
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))
)
}
)
}

View File

@@ -15,6 +15,7 @@ import org.signal.core.util.logging.Log
import org.signal.registration.RegistrationDependencies
import org.signal.registration.sample.dependencies.RealNetworkController
import org.signal.registration.sample.dependencies.RealStorageController
import org.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
@@ -29,13 +30,22 @@ 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)
val pushServiceSocket = createPushServiceSocket()
val networkController = RealNetworkController(this, pushServiceSocket)
RegistrationPreferences.init(this)
val trustStore = SampleTrustStore()
val configuration = createServiceConfiguration(trustStore)
val pushServiceSocket = createPushServiceSocket(configuration)
val networkController = RealNetworkController(this, pushServiceSocket, configuration, SVR2_MRENCLAVE)
val storageController = RealStorageController(this)
RegistrationDependencies.provide(
@@ -46,9 +56,7 @@ class RegistrationApplication : Application() {
)
}
private fun createPushServiceSocket(): PushServiceSocket {
val trustStore = SampleTrustStore()
val configuration = createServiceConfiguration(trustStore)
private fun createPushServiceSocket(configuration: SignalServiceConfiguration): PushServiceSocket {
val credentialsProvider = NoopCredentialsProvider()
val signalAgent = "Signal-Android/${BuildConfig.VERSION_NAME} Android/${Build.VERSION.SDK_INT}"

View File

@@ -8,8 +8,12 @@ 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
@@ -27,7 +31,19 @@ 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
@@ -37,7 +53,9 @@ import org.whispersystems.signalservice.api.account.PreKeyCollection as ServiceP
class RealNetworkController(
private val context: android.content.Context,
private val pushServiceSocket: PushServiceSocket
private val pushServiceSocket: PushServiceSocket,
private val serviceConfiguration: SignalServiceConfiguration,
private val svr2MrEnclave: String
) : NetworkController {
companion object {
@@ -46,6 +64,24 @@ class RealNetworkController(
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?,
@@ -273,6 +309,9 @@ class RealNetworkController(
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()))
}
@@ -323,6 +362,238 @@ class RealNetworkController(
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)
}
}
private fun AccountAttributes.toServiceAccountAttributes(): ServiceAccountAttributes {
return ServiceAccountAttributes(
signalingKey,

View File

@@ -5,16 +5,12 @@
package org.signal.registration.sample.dependencies
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.signal.core.models.AccountEntropyPool
import org.signal.core.util.Base64
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.ServiceId
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.kem.KEMKeyPair
import org.signal.libsignal.protocol.kem.KEMKeyType
@@ -25,19 +21,23 @@ 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 to a SQLite database.
* 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 = RegistrationDatabase(context)
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()
@@ -69,7 +69,8 @@ class RealStorageController(context: Context) : StorageController {
aciRegistrationId = aciRegistrationId,
pniRegistrationId = pniRegistrationId,
unidentifiedAccessKey = unidentifiedAccessKey,
servicePassword = password
servicePassword = password,
accountEntropyPool = accountEntropyPool
)
storeKeyMaterial(keyMaterial, profileKey)
@@ -78,176 +79,35 @@ class RealStorageController(context: Context) : StorageController {
}
override suspend fun saveNewRegistrationData(newRegistrationData: NewRegistrationData) = withContext(Dispatchers.IO) {
val database = db.writableDatabase
database.beginTransaction()
try {
database.delete(RegistrationDatabase.TABLE_ACCOUNT, null, null)
database.insert(
RegistrationDatabase.TABLE_ACCOUNT,
null,
ContentValues().apply {
put(RegistrationDatabase.COLUMN_E164, newRegistrationData.e164)
put(RegistrationDatabase.COLUMN_ACI, newRegistrationData.aci.toString())
put(RegistrationDatabase.COLUMN_PNI, newRegistrationData.pni.toString())
put(RegistrationDatabase.COLUMN_SERVICE_PASSWORD, newRegistrationData.servicePassword)
put(RegistrationDatabase.COLUMN_AEP, newRegistrationData.aep.toString())
}
)
database.setTransactionSuccessful()
} finally {
database.endTransaction()
}
RegistrationPreferences.saveRegistrationData(newRegistrationData)
}
override suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? = withContext(Dispatchers.IO) {
val database = db.readableDatabase
val cursor = database.query(
RegistrationDatabase.TABLE_ACCOUNT,
arrayOf(
RegistrationDatabase.COLUMN_E164,
RegistrationDatabase.COLUMN_ACI,
RegistrationDatabase.COLUMN_PNI,
RegistrationDatabase.COLUMN_SERVICE_PASSWORD,
RegistrationDatabase.COLUMN_AEP
),
null,
null,
null,
null,
null
)
cursor.use {
if (it.moveToFirst()) {
val e164 = it.getString(it.getColumnIndexOrThrow(RegistrationDatabase.COLUMN_E164))
val aciString = it.getString(it.getColumnIndexOrThrow(RegistrationDatabase.COLUMN_ACI))
val pniString = it.getString(it.getColumnIndexOrThrow(RegistrationDatabase.COLUMN_PNI))
val servicePassword = it.getString(it.getColumnIndexOrThrow(RegistrationDatabase.COLUMN_SERVICE_PASSWORD))
val aepValue = it.getString(it.getColumnIndexOrThrow(RegistrationDatabase.COLUMN_AEP))
PreExistingRegistrationData(
e164 = e164,
aci = ServiceId.Aci.parseFromString(aciString),
pni = ServiceId.Pni.parseFromString(pniString),
servicePassword = servicePassword,
aep = AccountEntropyPool(aepValue)
)
} else {
null
}
RegistrationPreferences.getPreExistingRegistrationData()
}
override suspend fun clearAllData() = withContext(Dispatchers.IO) {
RegistrationPreferences.clearAll()
db.clearAllPreKeys()
}
private fun storeKeyMaterial(keyMaterial: KeyMaterial, profileKey: ProfileKey) {
val database = db.writableDatabase
database.beginTransaction()
try {
// Clear any existing data
database.delete(RegistrationDatabase.TABLE_IDENTITY_KEYS, null, null)
database.delete(RegistrationDatabase.TABLE_SIGNED_PREKEYS, null, null)
database.delete(RegistrationDatabase.TABLE_KYBER_PREKEYS, null, null)
database.delete(RegistrationDatabase.TABLE_REGISTRATION_IDS, null, null)
database.delete(RegistrationDatabase.TABLE_PROFILE_KEY, null, null)
// Clear existing data
RegistrationPreferences.clearKeyMaterial()
db.clearAllPreKeys()
// Store ACI identity key
database.insert(
RegistrationDatabase.TABLE_IDENTITY_KEYS,
null,
ContentValues().apply {
put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_ACI)
put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.aciIdentityKeyPair.serialize())
}
)
// Store in SharedPreferences
RegistrationPreferences.aciIdentityKeyPair = keyMaterial.aciIdentityKeyPair
RegistrationPreferences.pniIdentityKeyPair = keyMaterial.pniIdentityKeyPair
RegistrationPreferences.aciRegistrationId = keyMaterial.aciRegistrationId
RegistrationPreferences.pniRegistrationId = keyMaterial.pniRegistrationId
RegistrationPreferences.profileKey = profileKey
// Store PNI identity key
database.insert(
RegistrationDatabase.TABLE_IDENTITY_KEYS,
null,
ContentValues().apply {
put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_PNI)
put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.pniIdentityKeyPair.serialize())
}
)
// Store ACI signed pre-key
database.insert(
RegistrationDatabase.TABLE_SIGNED_PREKEYS,
null,
ContentValues().apply {
put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_ACI)
put(RegistrationDatabase.COLUMN_KEY_ID, keyMaterial.aciSignedPreKey.id)
put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.aciSignedPreKey.serialize())
}
)
// Store PNI signed pre-key
database.insert(
RegistrationDatabase.TABLE_SIGNED_PREKEYS,
null,
ContentValues().apply {
put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_PNI)
put(RegistrationDatabase.COLUMN_KEY_ID, keyMaterial.pniSignedPreKey.id)
put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.pniSignedPreKey.serialize())
}
)
// Store ACI Kyber pre-key
database.insert(
RegistrationDatabase.TABLE_KYBER_PREKEYS,
null,
ContentValues().apply {
put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_ACI)
put(RegistrationDatabase.COLUMN_KEY_ID, keyMaterial.aciLastResortKyberPreKey.id)
put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.aciLastResortKyberPreKey.serialize())
}
)
// Store PNI Kyber pre-key
database.insert(
RegistrationDatabase.TABLE_KYBER_PREKEYS,
null,
ContentValues().apply {
put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_PNI)
put(RegistrationDatabase.COLUMN_KEY_ID, keyMaterial.pniLastResortKyberPreKey.id)
put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.pniLastResortKyberPreKey.serialize())
}
)
// Store ACI registration ID
database.insert(
RegistrationDatabase.TABLE_REGISTRATION_IDS,
null,
ContentValues().apply {
put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_ACI)
put(RegistrationDatabase.COLUMN_REGISTRATION_ID, keyMaterial.aciRegistrationId)
}
)
// Store PNI registration ID
database.insert(
RegistrationDatabase.TABLE_REGISTRATION_IDS,
null,
ContentValues().apply {
put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_PNI)
put(RegistrationDatabase.COLUMN_REGISTRATION_ID, keyMaterial.pniRegistrationId)
}
)
// Store profile key
database.insert(
RegistrationDatabase.TABLE_PROFILE_KEY,
null,
ContentValues().apply {
put(RegistrationDatabase.COLUMN_KEY_DATA, profileKey.serialize())
}
)
database.setTransactionSuccessful()
} finally {
database.endTransaction()
}
// 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 {
@@ -300,109 +160,4 @@ class RealStorageController(context: Context) : StorageController {
val ciphertext = cipher.doFinal(input)
return ciphertext.copyOf(16)
}
companion object {
private const val ACCOUNT_TYPE_ACI = "aci"
private const val ACCOUNT_TYPE_PNI = "pni"
}
private class RegistrationDatabase(context: Context) : SQLiteOpenHelper(
context,
DATABASE_NAME,
null,
DATABASE_VERSION
) {
companion object {
const val DATABASE_NAME = "registration.db"
const val DATABASE_VERSION = 1
const val TABLE_IDENTITY_KEYS = "identity_keys"
const val TABLE_SIGNED_PREKEYS = "signed_prekeys"
const val TABLE_KYBER_PREKEYS = "kyber_prekeys"
const val TABLE_REGISTRATION_IDS = "registration_ids"
const val TABLE_PROFILE_KEY = "profile_key"
const val TABLE_ACCOUNT = "account"
const val COLUMN_ID = "_id"
const val COLUMN_ACCOUNT_TYPE = "account_type"
const val COLUMN_KEY_ID = "key_id"
const val COLUMN_KEY_DATA = "key_data"
const val COLUMN_REGISTRATION_ID = "registration_id"
const val COLUMN_E164 = "e164"
const val COLUMN_ACI = "aci"
const val COLUMN_PNI = "pni"
const val COLUMN_SERVICE_PASSWORD = "service_password"
const val COLUMN_AEP = "aep"
}
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE $TABLE_IDENTITY_KEYS (
$COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT,
$COLUMN_ACCOUNT_TYPE TEXT NOT NULL UNIQUE,
$COLUMN_KEY_DATA BLOB NOT NULL
)
""".trimIndent()
)
db.execSQL(
"""
CREATE TABLE $TABLE_SIGNED_PREKEYS (
$COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT,
$COLUMN_ACCOUNT_TYPE TEXT NOT NULL,
$COLUMN_KEY_ID INTEGER NOT NULL,
$COLUMN_KEY_DATA BLOB NOT NULL
)
""".trimIndent()
)
db.execSQL(
"""
CREATE TABLE $TABLE_KYBER_PREKEYS (
$COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT,
$COLUMN_ACCOUNT_TYPE TEXT NOT NULL,
$COLUMN_KEY_ID INTEGER NOT NULL,
$COLUMN_KEY_DATA BLOB NOT NULL
)
""".trimIndent()
)
db.execSQL(
"""
CREATE TABLE $TABLE_REGISTRATION_IDS (
$COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT,
$COLUMN_ACCOUNT_TYPE TEXT NOT NULL UNIQUE,
$COLUMN_REGISTRATION_ID INTEGER NOT NULL
)
""".trimIndent()
)
db.execSQL(
"""
CREATE TABLE $TABLE_PROFILE_KEY (
$COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT,
$COLUMN_KEY_DATA BLOB NOT NULL
)
""".trimIndent()
)
db.execSQL(
"""
CREATE TABLE $TABLE_ACCOUNT (
$COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT,
$COLUMN_E164 TEXT NOT NULL,
$COLUMN_ACI TEXT NOT NULL,
$COLUMN_PNI TEXT NOT NULL,
$COLUMN_SERVICE_PASSWORD TEXT NOT NULL,
$COLUMN_AEP TEXT NOT NULL
)
""".trimIndent()
)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
// No migrations needed yet
}
}
}

View File

@@ -7,15 +7,29 @@ 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
@@ -26,6 +40,34 @@ fun MainScreen(
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()
@@ -33,6 +75,8 @@ fun MainScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Spacer(modifier = Modifier.height(32.dp))
Text(
text = "Registration Sample App",
style = MaterialTheme.typography.headlineMedium
@@ -44,23 +88,92 @@ fun MainScreen(
modifier = Modifier.padding(top = 8.dp)
)
Button(
onClick = { onEvent(MainScreenEvents.LaunchRegistration) },
modifier = Modifier
.fillMaxWidth()
.padding(top = 48.dp)
) {
Text("Start Registration")
}
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 = "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)
@@ -73,3 +186,23 @@ private fun MainScreenPreview() {
)
}
}
@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
)
),
onEvent = {}
)
}
}

View File

@@ -7,4 +7,6 @@ package org.signal.registration.sample.screens.main
sealed interface MainScreenEvents {
data object LaunchRegistration : MainScreenEvents
data object OpenPinSettings : MainScreenEvents
data object ClearAllData : MainScreenEvents
}

View File

@@ -8,5 +8,12 @@ package org.signal.registration.sample.screens.main
data class MainScreenState(
val existingRegistrationState: ExistingRegistrationState? = null
) {
data class ExistingRegistrationState(val phoneNumber: String)
data class ExistingRegistrationState(
val phoneNumber: String,
val aci: String,
val pni: String,
val aep: String,
val pin: String?,
val registrationLockEnabled: Boolean
)
}

View File

@@ -12,25 +12,66 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.signal.registration.StorageController
import org.signal.registration.sample.storage.RegistrationPreferences
class MainScreenViewModel(
private val onLaunchRegistration: () -> Unit
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()
}
}
}
}
class Factory(private val onLaunchRegistration: () -> Unit) : ViewModelProvider.Factory {
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
)
} 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(onLaunchRegistration) as T
return MainScreenViewModel(storageController, onLaunchRegistration, onOpenPinSettings) as T
}
}
}

View File

@@ -0,0 +1,13 @@
/*
* 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 Back : PinSettingsEvents
data object DismissMessage : PinSettingsEvents
}

View File

@@ -0,0 +1,264 @@
/*
* 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) {
onEvent(PinSettingsEvents.SetPin(pinInput))
}
}
),
enabled = !state.loading
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { onEvent(PinSettingsEvents.SetPin(pinInput)) },
modifier = Modifier.fillMaxWidth(),
enabled = pinInput.length >= 4 && !state.loading
) {
Text(if (state.hasPinSet) "Update PIN" else "Set PIN")
}
if (state.hasPinSet) {
Text(
text = "PIN is currently set",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
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
)
}
if (!state.hasPinSet) {
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
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Info Section
Text(
text = "Note: This is a sample app. PIN changes here are simulated and won't persist to the server.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 8.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 = {}
)
}
}

View File

@@ -0,0 +1,13 @@
/*
* 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 loading: Boolean = false,
val toastMessage: String? = null
)

View File

@@ -0,0 +1,166 @@
/*
* 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
)
)
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.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 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,147 @@
/*
* 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_MASTER_KEY = "master_key"
private const val KEY_REGISTRATION_LOCK_ENABLED = "registration_lock_enabled"
private const val KEY_PIN = "has_pin"
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 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) }
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

@@ -9,6 +9,7 @@ import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.signal.core.models.MasterKey
import org.signal.core.util.serialization.ByteArrayToBase64Serializer
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
@@ -101,6 +102,48 @@ interface NetworkController {
*/
fun getCaptchaUrl(): String
/**
* Attempts to restore the master key from SVR using the provided credentials and PIN.
*
* This is called when the user encounters a registration lock and needs to prove
* they know their PIN to proceed with registration.
*
* @param svr2Credentials The SVR2 credentials provided by the server during the registration lock response.
* @param pin The user-entered PIN.
* @return The restored master key on success, or an appropriate error.
*/
suspend fun restoreMasterKeyFromSvr(
svr2Credentials: SvrCredentials,
pin: String
): RegistrationNetworkResult<MasterKeyResponse, RestoreMasterKeyError>
/**
* Backs up the master key to SVR, protected by the user's PIN.
*
* @param pin The user-chosen PIN to protect the backup.
* @param masterKey The master key to backup.
* @return Success or an appropriate error.
*/
suspend fun setPinAndMasterKeyOnSvr(
pin: String,
masterKey: MasterKey
): RegistrationNetworkResult<Unit, BackupMasterKeyError>
/**
* Enables registration lock on the account using the registration lock token
* derived from the master key.
*
* @return Success or an appropriate error.
*/
suspend fun enableRegistrationLock(): RegistrationNetworkResult<Unit, SetRegistrationLockError>
/**
* Disables registration lock on the account.
*
* @return Success or an appropriate error.
*/
suspend fun disableRegistrationLock(): RegistrationNetworkResult<Unit, SetRegistrationLockError>
// TODO
// /**
// * Validates the provided SVR2 auth credentials, returning information on their usability.
@@ -176,6 +219,7 @@ interface NetworkController {
}
sealed class RegisterAccountError() {
data class SessionNotFoundOrNotVerified(val message: String) : RegisterAccountError()
data class RegistrationRecoveryPasswordIncorrect(val message: String) : RegisterAccountError()
data object DeviceTransferPossible : RegisterAccountError()
data class InvalidRequest(val message: String) : RegisterAccountError()
@@ -183,6 +227,27 @@ interface NetworkController {
data class RateLimited(val retryAfter: Duration) : RegisterAccountError()
}
sealed class RestoreMasterKeyError() {
data class WrongPin(val triesRemaining: Int) : RestoreMasterKeyError()
data object NoDataFound : RestoreMasterKeyError()
}
sealed class BackupMasterKeyError() {
data object EnclaveNotFound : BackupMasterKeyError()
data object NotRegistered : BackupMasterKeyError()
}
sealed class SetRegistrationLockError() {
data class InvalidRequest(val message: String) : SetRegistrationLockError()
data object Unauthorized : SetRegistrationLockError()
data object NotRegistered : SetRegistrationLockError()
data object NoPinSet : SetRegistrationLockError()
}
data class MasterKeyResponse(
val masterKey: MasterKey
)
@Serializable
@Parcelize
data class SessionMetadata(
@@ -261,14 +326,14 @@ interface NetworkController {
data class RegistrationLockResponse(
val timeRemaining: Long,
val svr2Credentials: SvrCredentials
) {
)
@Serializable
@Parcelize
data class SvrCredentials(
val username: String,
val password: String
)
}
) : Parcelable
@Serializable
data class ThirdPartyServiceErrorResponse(

View File

@@ -5,10 +5,13 @@
package org.signal.registration
import org.signal.core.models.MasterKey
sealed interface RegistrationFlowEvent {
data class NavigateToScreen(val route: RegistrationRoute) : RegistrationFlowEvent
data object NavigateBack : RegistrationFlowEvent
data object ResetState : RegistrationFlowEvent
data class SessionUpdated(val session: NetworkController.SessionMetadata) : RegistrationFlowEvent
data class E164Chosen(val e164: String) : RegistrationFlowEvent
data class MasterKeyRestoredForRegistrationLock(val masterKey: MasterKey) : RegistrationFlowEvent
}

View File

@@ -5,12 +5,30 @@
package org.signal.registration
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.signal.core.models.MasterKey
@Parcelize
@TypeParceler<MasterKey?, MasterKeyParceler>
data class RegistrationFlowState(
val backStack: List<RegistrationRoute> = listOf(RegistrationRoute.Welcome),
val sessionMetadata: NetworkController.SessionMetadata? = null,
val sessionE164: String? = null
val sessionE164: String? = null,
val masterKey: MasterKey? = null,
val registrationLockProof: String? = null
) : Parcelable
object MasterKeyParceler : Parceler<MasterKey?> {
override fun create(parcel: Parcel): MasterKey? {
val bytes = parcel.createByteArray()
return bytes?.let { MasterKey(it) }
}
override fun MasterKey?.write(parcel: Parcel, flags: Int) {
parcel.writeByteArray(this?.serialize())
}
}

View File

@@ -30,6 +30,9 @@ import com.google.accompanist.permissions.MultiplePermissionsState
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import org.signal.core.ui.navigation.ResultEffect
import org.signal.registration.screens.accountlocked.AccountLockedScreen
import org.signal.registration.screens.accountlocked.AccountLockedScreenEvents
import org.signal.registration.screens.accountlocked.AccountLockedState
import org.signal.registration.screens.captcha.CaptchaScreen
import org.signal.registration.screens.captcha.CaptchaScreenEvents
import org.signal.registration.screens.captcha.CaptchaState
@@ -40,6 +43,8 @@ import org.signal.registration.screens.phonenumber.PhoneNumberScreen
import org.signal.registration.screens.pincreation.PinCreationScreen
import org.signal.registration.screens.pincreation.PinCreationScreenEvents
import org.signal.registration.screens.pincreation.PinCreationState
import org.signal.registration.screens.pinentry.PinEntryScreen
import org.signal.registration.screens.registrationlock.RegistrationLockPinEntryViewModel
import org.signal.registration.screens.restore.RestoreViaQrScreen
import org.signal.registration.screens.restore.RestoreViaQrScreenEvents
import org.signal.registration.screens.restore.RestoreViaQrState
@@ -72,6 +77,18 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
@Serializable
data class Captcha(val session: NetworkController.SessionMetadata) : RegistrationRoute
@Serializable
data object PinEntry : RegistrationRoute
@Serializable
data class RegistrationLockPinEntry(
val timeRemaining: Long,
val svrCredentials: NetworkController.SvrCredentials
) : RegistrationRoute
@Serializable
data class AccountLocked(val timeRemainingMs: Long) : RegistrationRoute
@Serializable
data object Profile : RegistrationRoute
@@ -317,6 +334,44 @@ private fun EntryProviderScope<NavKey>.registrationEntries(
)
}
// -- Registration Lock PIN Entry Screen
entry<RegistrationRoute.RegistrationLockPinEntry> { key ->
val viewModel: RegistrationLockPinEntryViewModel = viewModel(
factory = RegistrationLockPinEntryViewModel.Factory(
repository = registrationRepository,
parentState = registrationViewModel.state,
parentEventEmitter = registrationViewModel::onEvent,
timeRemaining = key.timeRemaining,
svrCredentials = key.svrCredentials
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
PinEntryScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
)
}
// -- Account Locked Screen
entry<RegistrationRoute.AccountLocked> { key ->
val daysRemaining = (key.timeRemainingMs / (1000 * 60 * 60 * 24)).toInt()
AccountLockedScreen(
state = AccountLockedState(daysRemaining = daysRemaining),
onEvent = { event ->
when (event) {
AccountLockedScreenEvents.Next -> {
// TODO: Navigate to appropriate next screen (likely back to welcome or phone entry)
navigator.navigate(RegistrationRoute.Welcome)
}
AccountLockedScreenEvents.LearnMore -> {
// TODO: Open learn more URL
}
}
}
)
}
entry<RegistrationRoute.Restore> {
// TODO: Implement RestoreScreen
}

View File

@@ -7,14 +7,19 @@ package org.signal.registration
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.registration.NetworkController.AccountAttributes
import org.signal.registration.NetworkController.CreateSessionError
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.SvrCredentials
import org.signal.registration.NetworkController.UpdateSessionError
import java.util.Locale
@@ -81,6 +86,16 @@ class RegistrationRepository(val networkController: NetworkController, val stora
)
}
suspend fun restoreMasterKeyFromSvr(
svr2Credentials: SvrCredentials,
pin: String
): RegistrationNetworkResult<MasterKeyResponse, RestoreMasterKeyError> = withContext(Dispatchers.IO) {
networkController.restoreMasterKeyFromSvr(
svr2Credentials = svr2Credentials,
pin = pin
)
}
/**
* Registers a new account after successful phone number verification.
*
@@ -88,28 +103,30 @@ class RegistrationRepository(val networkController: NetworkController, val stora
* 1. Generates and stores all required cryptographic key material
* 2. Creates account attributes with registration IDs and capabilities
* 3. Calls the network controller to register the account
* 4. On success, saves the registration data to persistent storage
*
* @param e164 The phone number in E.164 format (used for basic auth)
* @param sessionId The verified session ID from phone number verification
* @param registrationLock The registration lock token derived from the master key (if unlocking a reglocked account)
* @param skipDeviceTransfer Whether to skip device transfer flow
* @return The registration result containing account information or an error
*/
suspend fun registerAccount(
e164: String,
sessionId: String,
registrationLock: String? = null,
skipDeviceTransfer: Boolean = true
): RegistrationNetworkResult<RegisterAccountResponse, RegisterAccountError> = withContext(Dispatchers.IO) {
val keyMaterial = storageController.generateAndStoreKeyMaterial()
val fcmToken = networkController.getFcmToken()
// TODO this will need to be re-usable for reglocked accounts too (i.e. can't assume no reglock)
val accountAttributes = AccountAttributes(
signalingKey = null,
registrationId = keyMaterial.aciRegistrationId,
voice = true,
video = true,
fetchesMessages = fcmToken == null,
registrationLock = null,
registrationLock = registrationLock,
unidentifiedAccessKey = keyMaterial.unidentifiedAccessKey,
unrestrictedUnidentifiedAccess = false,
discoverableByPhoneNumber = false, // Important -- this should be false initially, and then the user should be given a choice as to whether to turn it on later
@@ -136,7 +153,7 @@ class RegistrationRepository(val networkController: NetworkController, val stora
lastResortKyberPreKey = keyMaterial.pniLastResortKyberPreKey
)
networkController.registerAccount(
val result = networkController.registerAccount(
e164 = e164,
password = keyMaterial.servicePassword,
sessionId = sessionId,
@@ -147,5 +164,19 @@ class RegistrationRepository(val networkController: NetworkController, val stora
fcmToken = fcmToken,
skipDeviceTransfer = skipDeviceTransfer
)
if (result is RegistrationNetworkResult.Success) {
storageController.saveNewRegistrationData(
NewRegistrationData(
e164 = result.data.e164,
aci = ACI.parseOrThrow(result.data.aci),
pni = PNI.parseOrThrow(result.data.pni),
servicePassword = keyMaterial.servicePassword,
aep = keyMaterial.accountEntropyPool
)
)
}
result
}
}

View File

@@ -43,6 +43,7 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save
is RegistrationFlowEvent.ResetState -> RegistrationFlowState()
is RegistrationFlowEvent.SessionUpdated -> state.copy(sessionMetadata = event.session)
is RegistrationFlowEvent.E164Chosen -> state.copy(sessionE164 = event.e164)
is RegistrationFlowEvent.MasterKeyRestoredForRegistrationLock -> state.copy(masterKey = event.masterKey, registrationLockProof = event.masterKey.deriveRegistrationLock())
is RegistrationFlowEvent.NavigateToScreen -> applyNavigationToScreenEvent(state, event)
is RegistrationFlowEvent.NavigateBack -> state.copy(backStack = state.backStack.dropLast(1))
}

View File

@@ -6,8 +6,9 @@
package org.signal.registration
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.ServiceId
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
@@ -32,6 +33,11 @@ interface StorageController {
* @return Data for the existing registration if registered, otherwise null.
*/
suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData?
/**
* Clears all stored registration data, including key material and account information.
*/
suspend fun clearAllData()
}
/**
@@ -57,21 +63,23 @@ data class KeyMaterial(
/** Unidentified access key (derived from profile key) for sealed sender. */
val unidentifiedAccessKey: ByteArray,
/** Password for basic auth during registration (18 random bytes, base64 encoded). */
val servicePassword: String
val servicePassword: String,
/** Account entropy pool for key derivation. */
val accountEntropyPool: AccountEntropyPool
)
data class NewRegistrationData(
val e164: String,
val aci: ServiceId.Aci,
val pni: ServiceId.Pni,
val aci: ACI,
val pni: PNI,
val servicePassword: String,
val aep: AccountEntropyPool
)
data class PreExistingRegistrationData(
val e164: String,
val aci: ServiceId.Aci,
val pni: ServiceId.Pni,
val aci: ACI,
val pni: PNI,
val servicePassword: String,
val aep: AccountEntropyPool
)

View File

@@ -0,0 +1,98 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.accountlocked
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
/**
* Screen shown when the user's account is locked due to too many failed PIN attempts
* and there's no SVR data available to recover.
*/
@Composable
fun AccountLockedScreen(
state: AccountLockedState,
onEvent: (AccountLockedScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(49.dp))
Text(
text = "Account locked",
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Your account has been locked to protect your privacy and security. After ${state.daysRemaining} days of inactivity in your account you'll be able to re-register this phone number without needing your PIN. All content will be deleted.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(
onClick = { onEvent(AccountLockedScreenEvents.Next) },
modifier = Modifier.fillMaxWidth()
) {
Text("Next")
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(
onClick = { onEvent(AccountLockedScreenEvents.LearnMore) },
modifier = Modifier.fillMaxWidth()
) {
Text("Learn More")
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@DayNightPreviews
@Composable
private fun AccountLockedScreenPreview() {
Previews.Preview {
AccountLockedScreen(
state = AccountLockedState(daysRemaining = 7),
onEvent = {}
)
}
}

View File

@@ -0,0 +1,11 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.accountlocked
sealed class AccountLockedScreenEvents {
data object Next : AccountLockedScreenEvents()
data object LearnMore : AccountLockedScreenEvents()
}

View File

@@ -0,0 +1,10 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.accountlocked
data class AccountLockedState(
val daysRemaining: Int = 10
)

View File

@@ -54,18 +54,28 @@ fun PhoneNumberScreen(
onEvent: (PhoneNumberEntryScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
var simpleErrorMessage: String? by remember { mutableStateOf(null) }
LaunchedEffect(state.oneTimeEvent) {
onEvent(PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent)
when (state.oneTimeEvent) {
OneTimeEvent.NetworkError -> TODO()
is OneTimeEvent.RateLimited -> TODO()
OneTimeEvent.UnknownError -> TODO()
OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> TODO()
OneTimeEvent.ThirdPartyError -> TODO()
OneTimeEvent.NetworkError -> simpleErrorMessage = "Network error"
is OneTimeEvent.RateLimited -> simpleErrorMessage = "Rate limited"
OneTimeEvent.UnknownError -> simpleErrorMessage = "Unknown error"
OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> simpleErrorMessage = "Could not request code with selected transport"
OneTimeEvent.ThirdPartyError -> simpleErrorMessage = "Third party error"
null -> Unit
}
}
simpleErrorMessage?.let { message ->
Dialogs.SimpleMessageDialog(
message = message,
dismiss = "Ok",
onDismiss = { simpleErrorMessage = null }
)
}
Box(modifier = modifier.fillMaxSize()) {
ScreenContent(state, onEvent)

View File

@@ -48,10 +48,10 @@ class PhoneNumberEntryViewModel(
fun onEvent(event: PhoneNumberEntryScreenEvents) {
viewModelScope.launch {
val stateEMitter: (PhoneNumberEntryState) -> Unit = { state ->
val stateEmitter: (PhoneNumberEntryState) -> Unit = { state ->
_state.value = state
}
applyEvent(_state.value, event, stateEMitter, parentEventEmitter)
applyEvent(_state.value, event, stateEmitter, parentEventEmitter)
}
}
@@ -64,9 +64,10 @@ class PhoneNumberEntryViewModel(
stateEmitter(applyPhoneNumberChanged(state, event.value))
}
is PhoneNumberEntryScreenEvents.PhoneNumberSubmitted -> {
stateEmitter(state.copy(showFullScreenSpinner = true))
val resultState = applyPhoneNumberSubmitted(state, parentEventEmitter)
stateEmitter(resultState.copy(showFullScreenSpinner = false))
var localState = state.copy(showFullScreenSpinner = true)
stateEmitter(localState)
localState = applyPhoneNumberSubmitted(localState, parentEventEmitter)
stateEmitter(localState.copy(showFullScreenSpinner = false))
}
is PhoneNumberEntryScreenEvents.CountryPicker -> {
state.also { parentEventEmitter.navigateTo(RegistrationRoute.CountryCodePicker) }

View File

@@ -107,13 +107,13 @@ fun PinEntryScreen(
}
}
),
isError = state.errorMessage != null
isError = state.triesRemaining != null
)
if (state.errorMessage != null) {
if (state.triesRemaining != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = state.errorMessage,
text = "Incorrect PIN. ${state.triesRemaining} attempts remaining.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
@@ -205,7 +205,7 @@ private fun PinEntryScreenWithErrorPreview() {
Previews.Preview {
PinEntryScreen(
state = PinEntryState(
errorMessage = "Incorrect PIN. Try again.",
triesRemaining = 3,
showNeedHelp = true
),
onEvent = {}

View File

@@ -0,0 +1,16 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.pinentry
object PinEntryScreenEventHandler {
fun applyEvent(state: PinEntryState, event: PinEntryScreenEvents): PinEntryState {
return when (event) {
PinEntryScreenEvents.ToggleKeyboard -> state.copy(isNumericKeyboard = !state.isNumericKeyboard)
else -> throw UnsupportedOperationException("This even is not handled generically!")
}
}
}

View File

@@ -5,8 +5,18 @@
package org.signal.registration.screens.pinentry
import kotlin.time.Duration
data class PinEntryState(
val errorMessage: String? = null,
val showNeedHelp: Boolean = false,
val isNumericKeyboard: Boolean = true
)
val isNumericKeyboard: Boolean = true,
val loading: Boolean = false,
val triesRemaining: Int? = null,
val oneTimeEvent: OneTimeEvent? = null
) {
sealed interface OneTimeEvent {
data object NetworkError : OneTimeEvent
data class RateLimited(val retryAfter: Duration) : OneTimeEvent
data object UnknownError : OneTimeEvent
}
}

View File

@@ -0,0 +1,207 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.registrationlock
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.signal.core.models.MasterKey
import org.signal.core.util.logging.Log
import org.signal.registration.NetworkController
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationFlowState
import org.signal.registration.RegistrationRepository
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.pinentry.PinEntryScreenEventHandler
import org.signal.registration.screens.pinentry.PinEntryScreenEvents
import org.signal.registration.screens.pinentry.PinEntryState
import org.signal.registration.screens.util.navigateTo
/**
* ViewModel for the registration lock PIN entry screen.
*
* This screen is shown when the user attempts to register and their account
* is protected by a registration lock (PIN). The user must enter their PIN
* to proceed with registration.
*/
class RegistrationLockPinEntryViewModel(
private val repository: RegistrationRepository,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val timeRemaining: Long,
private val svrCredentials: NetworkController.SvrCredentials
) : ViewModel() {
companion object {
private val TAG = Log.tag(RegistrationLockPinEntryViewModel::class)
}
private val _state = MutableStateFlow(
PinEntryState(
showNeedHelp = true
)
)
val state: StateFlow<PinEntryState> = _state
.onEach { Log.d(TAG, "[State] $it") }
.stateIn(viewModelScope, SharingStarted.Eagerly, PinEntryState(showNeedHelp = true))
fun onEvent(event: PinEntryScreenEvents) {
viewModelScope.launch {
val stateEmitter: (PinEntryState) -> Unit = { state ->
_state.value = state
}
applyEvent(state.value, event, stateEmitter, parentEventEmitter)
}
}
private suspend fun applyEvent(state: PinEntryState, event: PinEntryScreenEvents, stateEmitter: (PinEntryState) -> Unit, parentEventEmitter: (RegistrationFlowEvent) -> Unit) {
when (event) {
is PinEntryScreenEvents.PinEntered -> {
var localState = state.copy(loading = true)
stateEmitter(localState)
localState = applyPinEntered(localState, event, parentEventEmitter)
stateEmitter(localState.copy(loading = false))
}
is PinEntryScreenEvents.Skip -> {
handleSkip()
}
is PinEntryScreenEvents.ToggleKeyboard,
is PinEntryScreenEvents.NeedHelp -> {
stateEmitter(PinEntryScreenEventHandler.applyEvent(state, event))
}
}
}
private suspend fun applyPinEntered(state: PinEntryState, event: PinEntryScreenEvents.PinEntered, parentEventEmitter: (RegistrationFlowEvent) -> Unit): PinEntryState {
Log.d(TAG, "[PinEntered] Attempting to restore master key from SVR...")
val restoreResult = repository.restoreMasterKeyFromSvr(svrCredentials, event.pin)
val masterKey: MasterKey = when (restoreResult) {
is NetworkController.RegistrationNetworkResult.Success -> {
Log.i(TAG, "[PinEntered] Successfully restored master key from SVR.")
restoreResult.data.masterKey
}
is NetworkController.RegistrationNetworkResult.Failure -> {
return when (restoreResult.error) {
is NetworkController.RestoreMasterKeyError.WrongPin -> {
Log.w(TAG, "[PinEntered] Wrong PIN. Tries remaining: ${restoreResult.error.triesRemaining}")
state.copy(triesRemaining = restoreResult.error.triesRemaining)
}
is NetworkController.RestoreMasterKeyError.NoDataFound -> {
Log.w(TAG, "[PinEntered] No SVR data found. Account is locked.")
parentEventEmitter.navigateTo(RegistrationRoute.AccountLocked(timeRemainingMs = timeRemaining))
state
}
}
}
is NetworkController.RegistrationNetworkResult.NetworkError -> {
Log.w(TAG, "[PinEntered] Network error when restoring master key.", restoreResult.exception)
return state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.NetworkError)
}
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
Log.w(TAG, "[PinEntered] Application error when restoring master key.", restoreResult.exception)
return state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
}
}
parentEventEmitter(RegistrationFlowEvent.MasterKeyRestoredForRegistrationLock(masterKey))
val registrationLockToken = masterKey.deriveRegistrationLock()
val e164 = parentState.value.sessionE164
val sessionId = parentState.value.sessionMetadata?.id
if (e164 == null || sessionId == null) {
Log.w(TAG, "[PinEntered] Missing e164 or sessionId. Resetting state.")
parentEventEmitter(RegistrationFlowEvent.ResetState)
return state
}
Log.d(TAG, "[PinEntered] Attempting to register with registration lock token...")
val registerResult = repository.registerAccount(
e164 = e164,
sessionId = sessionId,
registrationLock = registrationLockToken,
skipDeviceTransfer = true
)
return when (registerResult) {
is NetworkController.RegistrationNetworkResult.Success -> {
Log.i(TAG, "[PinEntered] Successfully registered!")
parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete(registerResult.data))
state
}
is NetworkController.RegistrationNetworkResult.Failure -> {
when (registerResult.error) {
is NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified -> {
Log.w(TAG, "[PinEntered] Session not found or verified: ${registerResult.error.message}")
TODO()
}
is NetworkController.RegisterAccountError.RegistrationLock -> {
Log.w(TAG, "[PinEntered] Still getting registration lock error after providing token. This shouldn't happen. Resetting state.")
parentEventEmitter(RegistrationFlowEvent.ResetState)
state
}
is NetworkController.RegisterAccountError.RateLimited -> {
Log.w(TAG, "[PinEntered] Rate limited when registering. Retry After: ${registerResult.error.retryAfter}")
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.RateLimited(registerResult.error.retryAfter))
}
is NetworkController.RegisterAccountError.InvalidRequest -> {
Log.w(TAG, "[PinEntered] Invalid request when registering: ${registerResult.error.message}")
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
}
is NetworkController.RegisterAccountError.DeviceTransferPossible -> {
Log.w(TAG, "[PinEntered] Device transfer possible. This shouldn't happen when skipDeviceTransfer is true.")
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
}
is NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> {
Log.w(TAG, "[PinEntered] Registration recovery password incorrect: ${registerResult.error.message}")
TODO()
}
}
}
is NetworkController.RegistrationNetworkResult.NetworkError -> {
Log.w(TAG, "[PinEntered] Network error when registering.", registerResult.exception)
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.NetworkError)
}
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
Log.w(TAG, "[PinEntered] Application error when registering.", registerResult.exception)
state.copy(oneTimeEvent = PinEntryState.OneTimeEvent.UnknownError)
}
}
}
private fun handleSkip() {
Log.d(TAG, "Skip requested - this will result in account data loss after timeRemaining: $timeRemaining ms")
// TODO: Show confirmation dialog warning about data loss, then proceed without PIN
}
class Factory(
private val repository: RegistrationRepository,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val timeRemaining: Long,
private val svrCredentials: NetworkController.SvrCredentials
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return RegistrationLockPinEntryViewModel(
repository,
parentState,
parentEventEmitter,
timeRemaining,
svrCredentials
) as T
}
}
}

View File

@@ -136,6 +136,9 @@ class VerificationCodeViewModel(
}
is NetworkController.RegistrationNetworkResult.Failure -> {
when (registerResult.error) {
is NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified -> {
TODO()
}
is NetworkController.RegisterAccountError.DeviceTransferPossible -> {
Log.w(TAG, "[Register] Got told a device transfer is possible. We should never get into this state. Resetting.")
parentEventEmitter(RegistrationFlowEvent.ResetState)
@@ -143,7 +146,13 @@ class VerificationCodeViewModel(
}
is NetworkController.RegisterAccountError.RegistrationLock -> {
Log.w(TAG, "[Register] Reglocked.")
TODO("reglock")
parentEventEmitter.navigateTo(
RegistrationRoute.RegistrationLockPinEntry(
timeRemaining = registerResult.error.data.timeRemaining,
svrCredentials = registerResult.error.data.svr2Credentials
)
)
state
}
is NetworkController.RegisterAccountError.RateLimited -> {
Log.w(TAG, "[Register] Rate limited.")