mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-20 02:58:45 +00:00
Add basic reglock support to regV5.
This commit is contained in:
committed by
Michelle Tang
parent
4b06e14df6
commit
7969df4e4c
@@ -6,6 +6,7 @@
|
|||||||
package org.signal.core.models
|
package org.signal.core.models
|
||||||
|
|
||||||
import org.signal.core.models.backup.MessageBackupKey
|
import org.signal.core.models.backup.MessageBackupKey
|
||||||
|
import org.signal.core.util.logging.Log
|
||||||
|
|
||||||
private typealias LibSignalAccountEntropyPool = org.signal.libsignal.messagebackup.AccountEntropyPool
|
private typealias LibSignalAccountEntropyPool = org.signal.libsignal.messagebackup.AccountEntropyPool
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ dependencies {
|
|||||||
// AndroidX
|
// AndroidX
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.sqlite)
|
||||||
|
implementation(libs.androidx.sqlite.framework)
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
|||||||
@@ -10,12 +10,25 @@ import androidx.activity.ComponentActivity
|
|||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.viewModels
|
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.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
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.compose.theme.SignalTheme
|
||||||
import org.signal.core.ui.navigation.ResultEffect
|
import org.signal.core.ui.navigation.ResultEffect
|
||||||
import org.signal.core.ui.navigation.ResultEventBus
|
import org.signal.core.ui.navigation.ResultEventBus
|
||||||
|
import org.signal.registration.NetworkController
|
||||||
import org.signal.registration.RegistrationActivity
|
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.MainActivity.Companion.REGISTRATION_RESULT
|
||||||
import org.signal.registration.sample.screens.RegistrationCompleteScreen
|
import org.signal.registration.sample.screens.RegistrationCompleteScreen
|
||||||
import org.signal.registration.sample.screens.main.MainScreen
|
import org.signal.registration.sample.screens.main.MainScreen
|
||||||
import org.signal.registration.sample.screens.main.MainScreenViewModel
|
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.
|
* Navigation routes for the sample app.
|
||||||
@@ -45,6 +91,9 @@ sealed interface SampleRoute : NavKey {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object RegistrationComplete : SampleRoute
|
data object RegistrationComplete : SampleRoute
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object PinSettings : SampleRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,6 +125,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
onLaunchRegistration = { registrationLauncher.launch(Unit) },
|
onLaunchRegistration = { registrationLauncher.launch(Unit) },
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
resultEventBus = viewModel.resultEventBus,
|
resultEventBus = viewModel.resultEventBus,
|
||||||
|
storageController = RegistrationDependencies.get().storageController,
|
||||||
|
networkController = RegistrationDependencies.get().networkController,
|
||||||
onStartOver = {
|
onStartOver = {
|
||||||
backStack.clear()
|
backStack.clear()
|
||||||
backStack.add(SampleRoute.Main)
|
backStack.add(SampleRoute.Main)
|
||||||
@@ -93,17 +144,29 @@ private fun SampleNavHost(
|
|||||||
onStartOver: () -> Unit,
|
onStartOver: () -> Unit,
|
||||||
backStack: NavBackStack<NavKey>,
|
backStack: NavBackStack<NavKey>,
|
||||||
resultEventBus: ResultEventBus,
|
resultEventBus: ResultEventBus,
|
||||||
|
storageController: StorageController,
|
||||||
|
networkController: NetworkController,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val entryProvider: (NavKey) -> NavEntry<NavKey> = entryProvider {
|
val entryProvider: (NavKey) -> NavEntry<NavKey> = entryProvider {
|
||||||
entry<SampleRoute.Main> {
|
entry<SampleRoute.Main> {
|
||||||
val viewModel: MainScreenViewModel = viewModel(
|
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()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LifecycleResumeEffect(Unit) {
|
||||||
|
viewModel.refreshData()
|
||||||
|
onPauseOrDispose { }
|
||||||
|
}
|
||||||
|
|
||||||
ResultEffect<Boolean>(resultEventBus, REGISTRATION_RESULT) { success ->
|
ResultEffect<Boolean>(resultEventBus, REGISTRATION_RESULT) { success ->
|
||||||
if (success) {
|
if (success) {
|
||||||
|
viewModel.refreshData()
|
||||||
backStack.add(SampleRoute.RegistrationComplete)
|
backStack.add(SampleRoute.RegistrationComplete)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,6 +180,23 @@ private fun SampleNavHost(
|
|||||||
entry<SampleRoute.RegistrationComplete> {
|
entry<SampleRoute.RegistrationComplete> {
|
||||||
RegistrationCompleteScreen(onStartOver = onStartOver)
|
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(
|
val decorators = listOf(
|
||||||
@@ -131,7 +211,51 @@ private fun SampleNavHost(
|
|||||||
|
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
entries = entries,
|
entries = entries,
|
||||||
onBack = {},
|
onBack = { backStack.removeLastOrNull() },
|
||||||
modifier = modifier
|
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))
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import org.signal.core.util.logging.Log
|
|||||||
import org.signal.registration.RegistrationDependencies
|
import org.signal.registration.RegistrationDependencies
|
||||||
import org.signal.registration.sample.dependencies.RealNetworkController
|
import org.signal.registration.sample.dependencies.RealNetworkController
|
||||||
import org.signal.registration.sample.dependencies.RealStorageController
|
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.push.TrustStore
|
||||||
import org.whispersystems.signalservice.api.util.CredentialsProvider
|
import org.whispersystems.signalservice.api.util.CredentialsProvider
|
||||||
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl
|
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl
|
||||||
@@ -29,13 +30,22 @@ import java.util.Optional
|
|||||||
|
|
||||||
class RegistrationApplication : Application() {
|
class RegistrationApplication : Application() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Staging SVR2 mrEnclave value
|
||||||
|
private const val SVR2_MRENCLAVE = "a75542d82da9f6914a1e31f8a7407053b99cc99a0e7291d8fbd394253e19b036"
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
Log.initialize(AndroidLogger)
|
Log.initialize(AndroidLogger)
|
||||||
|
|
||||||
val pushServiceSocket = createPushServiceSocket()
|
RegistrationPreferences.init(this)
|
||||||
val networkController = RealNetworkController(this, pushServiceSocket)
|
|
||||||
|
val trustStore = SampleTrustStore()
|
||||||
|
val configuration = createServiceConfiguration(trustStore)
|
||||||
|
val pushServiceSocket = createPushServiceSocket(configuration)
|
||||||
|
val networkController = RealNetworkController(this, pushServiceSocket, configuration, SVR2_MRENCLAVE)
|
||||||
val storageController = RealStorageController(this)
|
val storageController = RealStorageController(this)
|
||||||
|
|
||||||
RegistrationDependencies.provide(
|
RegistrationDependencies.provide(
|
||||||
@@ -46,9 +56,7 @@ class RegistrationApplication : Application() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createPushServiceSocket(): PushServiceSocket {
|
private fun createPushServiceSocket(configuration: SignalServiceConfiguration): PushServiceSocket {
|
||||||
val trustStore = SampleTrustStore()
|
|
||||||
val configuration = createServiceConfiguration(trustStore)
|
|
||||||
val credentialsProvider = NoopCredentialsProvider()
|
val credentialsProvider = NoopCredentialsProvider()
|
||||||
val signalAgent = "Signal-Android/${BuildConfig.VERSION_NAME} Android/${Build.VERSION.SDK_INT}"
|
val signalAgent = "Signal-Android/${BuildConfig.VERSION_NAME} Android/${Build.VERSION.SDK_INT}"
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ package org.signal.registration.sample.dependencies
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
import org.signal.core.models.MasterKey
|
||||||
import org.signal.core.util.logging.Log
|
import org.signal.core.util.logging.Log
|
||||||
|
import org.signal.libsignal.net.Network
|
||||||
import org.signal.registration.NetworkController
|
import org.signal.registration.NetworkController
|
||||||
import org.signal.registration.NetworkController.AccountAttributes
|
import org.signal.registration.NetworkController.AccountAttributes
|
||||||
import org.signal.registration.NetworkController.CreateSessionError
|
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.NetworkController.VerificationCodeTransport
|
||||||
import org.signal.registration.sample.fcm.FcmUtil
|
import org.signal.registration.sample.fcm.FcmUtil
|
||||||
import org.signal.registration.sample.fcm.PushChallengeReceiver
|
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.push.PushServiceSocket
|
||||||
|
import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider
|
||||||
|
import org.whispersystems.signalservice.internal.websocket.LibSignalChatConnection
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
@@ -37,7 +53,9 @@ import org.whispersystems.signalservice.api.account.PreKeyCollection as ServiceP
|
|||||||
|
|
||||||
class RealNetworkController(
|
class RealNetworkController(
|
||||||
private val context: android.content.Context,
|
private val context: android.content.Context,
|
||||||
private val pushServiceSocket: PushServiceSocket
|
private val pushServiceSocket: PushServiceSocket,
|
||||||
|
private val serviceConfiguration: SignalServiceConfiguration,
|
||||||
|
private val svr2MrEnclave: String
|
||||||
) : NetworkController {
|
) : NetworkController {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -46,6 +64,24 @@ class RealNetworkController(
|
|||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
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(
|
override suspend fun createSession(
|
||||||
e164: String,
|
e164: String,
|
||||||
fcmToken: String?,
|
fcmToken: String?,
|
||||||
@@ -273,6 +309,9 @@ class RealNetworkController(
|
|||||||
val result = json.decodeFromString<RegisterAccountResponse>(response.body.string())
|
val result = json.decodeFromString<RegisterAccountResponse>(response.body.string())
|
||||||
RegistrationNetworkResult.Success(result)
|
RegistrationNetworkResult.Success(result)
|
||||||
}
|
}
|
||||||
|
401 -> {
|
||||||
|
RegistrationNetworkResult.Failure(RegisterAccountError.SessionNotFoundOrNotVerified(response.body.string()))
|
||||||
|
}
|
||||||
403 -> {
|
403 -> {
|
||||||
RegistrationNetworkResult.Failure(RegisterAccountError.RegistrationRecoveryPasswordIncorrect(response.body.string()))
|
RegistrationNetworkResult.Failure(RegisterAccountError.RegistrationRecoveryPasswordIncorrect(response.body.string()))
|
||||||
}
|
}
|
||||||
@@ -323,6 +362,238 @@ class RealNetworkController(
|
|||||||
return "https://signalcaptchas.org/staging/registration/generate.html"
|
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 {
|
private fun AccountAttributes.toServiceAccountAttributes(): ServiceAccountAttributes {
|
||||||
return ServiceAccountAttributes(
|
return ServiceAccountAttributes(
|
||||||
signalingKey,
|
signalingKey,
|
||||||
|
|||||||
@@ -5,16 +5,12 @@
|
|||||||
|
|
||||||
package org.signal.registration.sample.dependencies
|
package org.signal.registration.sample.dependencies
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import android.database.sqlite.SQLiteOpenHelper
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.signal.core.models.AccountEntropyPool
|
import org.signal.core.models.AccountEntropyPool
|
||||||
import org.signal.core.util.Base64
|
import org.signal.core.util.Base64
|
||||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||||
import org.signal.libsignal.protocol.ServiceId
|
|
||||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||||
import org.signal.libsignal.protocol.kem.KEMKeyPair
|
import org.signal.libsignal.protocol.kem.KEMKeyPair
|
||||||
import org.signal.libsignal.protocol.kem.KEMKeyType
|
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.NewRegistrationData
|
||||||
import org.signal.registration.PreExistingRegistrationData
|
import org.signal.registration.PreExistingRegistrationData
|
||||||
import org.signal.registration.StorageController
|
import org.signal.registration.StorageController
|
||||||
|
import org.signal.registration.sample.storage.RegistrationDatabase
|
||||||
|
import org.signal.registration.sample.storage.RegistrationPreferences
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.spec.GCMParameterSpec
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
import javax.crypto.spec.SecretKeySpec
|
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 {
|
class RealStorageController(context: Context) : StorageController {
|
||||||
|
|
||||||
private val db: RegistrationDatabase = RegistrationDatabase(context)
|
private val db = RegistrationDatabase(context)
|
||||||
|
|
||||||
override suspend fun generateAndStoreKeyMaterial(): KeyMaterial = withContext(Dispatchers.IO) {
|
override suspend fun generateAndStoreKeyMaterial(): KeyMaterial = withContext(Dispatchers.IO) {
|
||||||
|
val accountEntropyPool = AccountEntropyPool.generate()
|
||||||
val aciIdentityKeyPair = IdentityKeyPair.generate()
|
val aciIdentityKeyPair = IdentityKeyPair.generate()
|
||||||
val pniIdentityKeyPair = IdentityKeyPair.generate()
|
val pniIdentityKeyPair = IdentityKeyPair.generate()
|
||||||
|
|
||||||
@@ -69,7 +69,8 @@ class RealStorageController(context: Context) : StorageController {
|
|||||||
aciRegistrationId = aciRegistrationId,
|
aciRegistrationId = aciRegistrationId,
|
||||||
pniRegistrationId = pniRegistrationId,
|
pniRegistrationId = pniRegistrationId,
|
||||||
unidentifiedAccessKey = unidentifiedAccessKey,
|
unidentifiedAccessKey = unidentifiedAccessKey,
|
||||||
servicePassword = password
|
servicePassword = password,
|
||||||
|
accountEntropyPool = accountEntropyPool
|
||||||
)
|
)
|
||||||
|
|
||||||
storeKeyMaterial(keyMaterial, profileKey)
|
storeKeyMaterial(keyMaterial, profileKey)
|
||||||
@@ -78,176 +79,35 @@ class RealStorageController(context: Context) : StorageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun saveNewRegistrationData(newRegistrationData: NewRegistrationData) = withContext(Dispatchers.IO) {
|
override suspend fun saveNewRegistrationData(newRegistrationData: NewRegistrationData) = withContext(Dispatchers.IO) {
|
||||||
val database = db.writableDatabase
|
RegistrationPreferences.saveRegistrationData(newRegistrationData)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? = withContext(Dispatchers.IO) {
|
override suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? = withContext(Dispatchers.IO) {
|
||||||
val database = db.readableDatabase
|
RegistrationPreferences.getPreExistingRegistrationData()
|
||||||
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 {
|
override suspend fun clearAllData() = withContext(Dispatchers.IO) {
|
||||||
if (it.moveToFirst()) {
|
RegistrationPreferences.clearAll()
|
||||||
val e164 = it.getString(it.getColumnIndexOrThrow(RegistrationDatabase.COLUMN_E164))
|
db.clearAllPreKeys()
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun storeKeyMaterial(keyMaterial: KeyMaterial, profileKey: ProfileKey) {
|
private fun storeKeyMaterial(keyMaterial: KeyMaterial, profileKey: ProfileKey) {
|
||||||
val database = db.writableDatabase
|
// Clear existing data
|
||||||
database.beginTransaction()
|
RegistrationPreferences.clearKeyMaterial()
|
||||||
try {
|
db.clearAllPreKeys()
|
||||||
// Clear any existing data
|
|
||||||
database.delete(RegistrationDatabase.TABLE_IDENTITY_KEYS, null, null)
|
|
||||||
database.delete(RegistrationDatabase.TABLE_SIGNED_PREKEYS, null, null)
|
|
||||||
database.delete(RegistrationDatabase.TABLE_KYBER_PREKEYS, null, null)
|
|
||||||
database.delete(RegistrationDatabase.TABLE_REGISTRATION_IDS, null, null)
|
|
||||||
database.delete(RegistrationDatabase.TABLE_PROFILE_KEY, null, null)
|
|
||||||
|
|
||||||
// Store ACI identity key
|
// Store in SharedPreferences
|
||||||
database.insert(
|
RegistrationPreferences.aciIdentityKeyPair = keyMaterial.aciIdentityKeyPair
|
||||||
RegistrationDatabase.TABLE_IDENTITY_KEYS,
|
RegistrationPreferences.pniIdentityKeyPair = keyMaterial.pniIdentityKeyPair
|
||||||
null,
|
RegistrationPreferences.aciRegistrationId = keyMaterial.aciRegistrationId
|
||||||
ContentValues().apply {
|
RegistrationPreferences.pniRegistrationId = keyMaterial.pniRegistrationId
|
||||||
put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_ACI)
|
RegistrationPreferences.profileKey = profileKey
|
||||||
put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.aciIdentityKeyPair.serialize())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Store PNI identity key
|
// Store prekeys in database
|
||||||
database.insert(
|
db.signedPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_ACI, keyMaterial.aciSignedPreKey)
|
||||||
RegistrationDatabase.TABLE_IDENTITY_KEYS,
|
db.signedPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_PNI, keyMaterial.pniSignedPreKey)
|
||||||
null,
|
db.kyberPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_ACI, keyMaterial.aciLastResortKyberPreKey)
|
||||||
ContentValues().apply {
|
db.kyberPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_PNI, keyMaterial.pniLastResortKyberPreKey)
|
||||||
put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_PNI)
|
|
||||||
put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.pniIdentityKeyPair.serialize())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Store ACI signed pre-key
|
|
||||||
database.insert(
|
|
||||||
RegistrationDatabase.TABLE_SIGNED_PREKEYS,
|
|
||||||
null,
|
|
||||||
ContentValues().apply {
|
|
||||||
put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_ACI)
|
|
||||||
put(RegistrationDatabase.COLUMN_KEY_ID, keyMaterial.aciSignedPreKey.id)
|
|
||||||
put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.aciSignedPreKey.serialize())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Store PNI signed pre-key
|
|
||||||
database.insert(
|
|
||||||
RegistrationDatabase.TABLE_SIGNED_PREKEYS,
|
|
||||||
null,
|
|
||||||
ContentValues().apply {
|
|
||||||
put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_PNI)
|
|
||||||
put(RegistrationDatabase.COLUMN_KEY_ID, keyMaterial.pniSignedPreKey.id)
|
|
||||||
put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.pniSignedPreKey.serialize())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Store ACI Kyber pre-key
|
|
||||||
database.insert(
|
|
||||||
RegistrationDatabase.TABLE_KYBER_PREKEYS,
|
|
||||||
null,
|
|
||||||
ContentValues().apply {
|
|
||||||
put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_ACI)
|
|
||||||
put(RegistrationDatabase.COLUMN_KEY_ID, keyMaterial.aciLastResortKyberPreKey.id)
|
|
||||||
put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.aciLastResortKyberPreKey.serialize())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Store PNI Kyber pre-key
|
|
||||||
database.insert(
|
|
||||||
RegistrationDatabase.TABLE_KYBER_PREKEYS,
|
|
||||||
null,
|
|
||||||
ContentValues().apply {
|
|
||||||
put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_PNI)
|
|
||||||
put(RegistrationDatabase.COLUMN_KEY_ID, keyMaterial.pniLastResortKyberPreKey.id)
|
|
||||||
put(RegistrationDatabase.COLUMN_KEY_DATA, keyMaterial.pniLastResortKyberPreKey.serialize())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Store ACI registration ID
|
|
||||||
database.insert(
|
|
||||||
RegistrationDatabase.TABLE_REGISTRATION_IDS,
|
|
||||||
null,
|
|
||||||
ContentValues().apply {
|
|
||||||
put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_ACI)
|
|
||||||
put(RegistrationDatabase.COLUMN_REGISTRATION_ID, keyMaterial.aciRegistrationId)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Store PNI registration ID
|
|
||||||
database.insert(
|
|
||||||
RegistrationDatabase.TABLE_REGISTRATION_IDS,
|
|
||||||
null,
|
|
||||||
ContentValues().apply {
|
|
||||||
put(RegistrationDatabase.COLUMN_ACCOUNT_TYPE, ACCOUNT_TYPE_PNI)
|
|
||||||
put(RegistrationDatabase.COLUMN_REGISTRATION_ID, keyMaterial.pniRegistrationId)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Store profile key
|
|
||||||
database.insert(
|
|
||||||
RegistrationDatabase.TABLE_PROFILE_KEY,
|
|
||||||
null,
|
|
||||||
ContentValues().apply {
|
|
||||||
put(RegistrationDatabase.COLUMN_KEY_DATA, profileKey.serialize())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
database.setTransactionSuccessful()
|
|
||||||
} finally {
|
|
||||||
database.endTransaction()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun generateSignedPreKey(id: Int, timestamp: Long, identityKeyPair: IdentityKeyPair): SignedPreKeyRecord {
|
private fun generateSignedPreKey(id: Int, timestamp: Long, identityKeyPair: IdentityKeyPair): SignedPreKeyRecord {
|
||||||
@@ -300,109 +160,4 @@ class RealStorageController(context: Context) : StorageController {
|
|||||||
val ciphertext = cipher.doFinal(input)
|
val ciphertext = cipher.doFinal(input)
|
||||||
return ciphertext.copyOf(16)
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,29 @@ package org.signal.registration.sample.screens.main
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
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.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.signal.core.ui.compose.Previews
|
import org.signal.core.ui.compose.Previews
|
||||||
@@ -26,6 +40,34 @@ fun MainScreen(
|
|||||||
onEvent: (MainScreenEvents) -> Unit,
|
onEvent: (MainScreenEvents) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
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(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -33,6 +75,8 @@ fun MainScreen(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Registration Sample App",
|
text = "Registration Sample App",
|
||||||
style = MaterialTheme.typography.headlineMedium
|
style = MaterialTheme.typography.headlineMedium
|
||||||
@@ -44,23 +88,92 @@ fun MainScreen(
|
|||||||
modifier = Modifier.padding(top = 8.dp)
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Button(
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
onClick = { onEvent(MainScreenEvents.LaunchRegistration) },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 48.dp)
|
|
||||||
) {
|
|
||||||
Text("Start Registration")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.existingRegistrationState != null) {
|
if (state.existingRegistrationState != null) {
|
||||||
RegistrationInfo(state.existingRegistrationState)
|
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
|
@Composable
|
||||||
private fun RegistrationInfo(data: MainScreenState.ExistingRegistrationState) {
|
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)
|
@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 = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,4 +7,6 @@ package org.signal.registration.sample.screens.main
|
|||||||
|
|
||||||
sealed interface MainScreenEvents {
|
sealed interface MainScreenEvents {
|
||||||
data object LaunchRegistration : MainScreenEvents
|
data object LaunchRegistration : MainScreenEvents
|
||||||
|
data object OpenPinSettings : MainScreenEvents
|
||||||
|
data object ClearAllData : MainScreenEvents
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,12 @@ package org.signal.registration.sample.screens.main
|
|||||||
data class MainScreenState(
|
data class MainScreenState(
|
||||||
val existingRegistrationState: ExistingRegistrationState? = null
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,25 +12,66 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.signal.registration.StorageController
|
||||||
|
import org.signal.registration.sample.storage.RegistrationPreferences
|
||||||
|
|
||||||
class MainScreenViewModel(
|
class MainScreenViewModel(
|
||||||
private val onLaunchRegistration: () -> Unit
|
private val storageController: StorageController,
|
||||||
|
private val onLaunchRegistration: () -> Unit,
|
||||||
|
private val onOpenPinSettings: () -> Unit
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _state = MutableStateFlow(MainScreenState())
|
private val _state = MutableStateFlow(MainScreenState())
|
||||||
val state: StateFlow<MainScreenState> = _state.asStateFlow()
|
val state: StateFlow<MainScreenState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadRegistrationData()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshData() {
|
||||||
|
loadRegistrationData()
|
||||||
|
}
|
||||||
|
|
||||||
fun onEvent(event: MainScreenEvents) {
|
fun onEvent(event: MainScreenEvents) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (event) {
|
when (event) {
|
||||||
MainScreenEvents.LaunchRegistration -> onLaunchRegistration()
|
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 {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
return MainScreenViewModel(onLaunchRegistration) as T
|
return MainScreenViewModel(storageController, onLaunchRegistration, onOpenPinSettings) as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import android.os.Parcelable
|
|||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.signal.core.models.MasterKey
|
||||||
import org.signal.core.util.serialization.ByteArrayToBase64Serializer
|
import org.signal.core.util.serialization.ByteArrayToBase64Serializer
|
||||||
import org.signal.libsignal.protocol.IdentityKey
|
import org.signal.libsignal.protocol.IdentityKey
|
||||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||||
@@ -101,6 +102,48 @@ interface NetworkController {
|
|||||||
*/
|
*/
|
||||||
fun getCaptchaUrl(): String
|
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
|
// TODO
|
||||||
// /**
|
// /**
|
||||||
// * Validates the provided SVR2 auth credentials, returning information on their usability.
|
// * Validates the provided SVR2 auth credentials, returning information on their usability.
|
||||||
@@ -176,6 +219,7 @@ interface NetworkController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sealed class RegisterAccountError() {
|
sealed class RegisterAccountError() {
|
||||||
|
data class SessionNotFoundOrNotVerified(val message: String) : RegisterAccountError()
|
||||||
data class RegistrationRecoveryPasswordIncorrect(val message: String) : RegisterAccountError()
|
data class RegistrationRecoveryPasswordIncorrect(val message: String) : RegisterAccountError()
|
||||||
data object DeviceTransferPossible : RegisterAccountError()
|
data object DeviceTransferPossible : RegisterAccountError()
|
||||||
data class InvalidRequest(val message: String) : RegisterAccountError()
|
data class InvalidRequest(val message: String) : RegisterAccountError()
|
||||||
@@ -183,6 +227,27 @@ interface NetworkController {
|
|||||||
data class RateLimited(val retryAfter: Duration) : RegisterAccountError()
|
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
|
@Serializable
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class SessionMetadata(
|
data class SessionMetadata(
|
||||||
@@ -261,14 +326,14 @@ interface NetworkController {
|
|||||||
data class RegistrationLockResponse(
|
data class RegistrationLockResponse(
|
||||||
val timeRemaining: Long,
|
val timeRemaining: Long,
|
||||||
val svr2Credentials: SvrCredentials
|
val svr2Credentials: SvrCredentials
|
||||||
) {
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SvrCredentials(
|
@Parcelize
|
||||||
val username: String,
|
data class SvrCredentials(
|
||||||
val password: String
|
val username: String,
|
||||||
)
|
val password: String
|
||||||
}
|
) : Parcelable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ThirdPartyServiceErrorResponse(
|
data class ThirdPartyServiceErrorResponse(
|
||||||
|
|||||||
@@ -5,10 +5,13 @@
|
|||||||
|
|
||||||
package org.signal.registration
|
package org.signal.registration
|
||||||
|
|
||||||
|
import org.signal.core.models.MasterKey
|
||||||
|
|
||||||
sealed interface RegistrationFlowEvent {
|
sealed interface RegistrationFlowEvent {
|
||||||
data class NavigateToScreen(val route: RegistrationRoute) : RegistrationFlowEvent
|
data class NavigateToScreen(val route: RegistrationRoute) : RegistrationFlowEvent
|
||||||
data object NavigateBack : RegistrationFlowEvent
|
data object NavigateBack : RegistrationFlowEvent
|
||||||
data object ResetState : RegistrationFlowEvent
|
data object ResetState : RegistrationFlowEvent
|
||||||
data class SessionUpdated(val session: NetworkController.SessionMetadata) : RegistrationFlowEvent
|
data class SessionUpdated(val session: NetworkController.SessionMetadata) : RegistrationFlowEvent
|
||||||
data class E164Chosen(val e164: String) : RegistrationFlowEvent
|
data class E164Chosen(val e164: String) : RegistrationFlowEvent
|
||||||
|
data class MasterKeyRestoredForRegistrationLock(val masterKey: MasterKey) : RegistrationFlowEvent
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,30 @@
|
|||||||
|
|
||||||
package org.signal.registration
|
package org.signal.registration
|
||||||
|
|
||||||
|
import android.os.Parcel
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parceler
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import kotlinx.parcelize.TypeParceler
|
||||||
|
import org.signal.core.models.MasterKey
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
|
@TypeParceler<MasterKey?, MasterKeyParceler>
|
||||||
data class RegistrationFlowState(
|
data class RegistrationFlowState(
|
||||||
val backStack: List<RegistrationRoute> = listOf(RegistrationRoute.Welcome),
|
val backStack: List<RegistrationRoute> = listOf(RegistrationRoute.Welcome),
|
||||||
val sessionMetadata: NetworkController.SessionMetadata? = null,
|
val sessionMetadata: NetworkController.SessionMetadata? = null,
|
||||||
val sessionE164: String? = null
|
val sessionE164: String? = null,
|
||||||
|
val masterKey: MasterKey? = null,
|
||||||
|
val registrationLockProof: String? = null
|
||||||
) : Parcelable
|
) : 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ import com.google.accompanist.permissions.MultiplePermissionsState
|
|||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import org.signal.core.ui.navigation.ResultEffect
|
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.CaptchaScreen
|
||||||
import org.signal.registration.screens.captcha.CaptchaScreenEvents
|
import org.signal.registration.screens.captcha.CaptchaScreenEvents
|
||||||
import org.signal.registration.screens.captcha.CaptchaState
|
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.PinCreationScreen
|
||||||
import org.signal.registration.screens.pincreation.PinCreationScreenEvents
|
import org.signal.registration.screens.pincreation.PinCreationScreenEvents
|
||||||
import org.signal.registration.screens.pincreation.PinCreationState
|
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.RestoreViaQrScreen
|
||||||
import org.signal.registration.screens.restore.RestoreViaQrScreenEvents
|
import org.signal.registration.screens.restore.RestoreViaQrScreenEvents
|
||||||
import org.signal.registration.screens.restore.RestoreViaQrState
|
import org.signal.registration.screens.restore.RestoreViaQrState
|
||||||
@@ -72,6 +77,18 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class Captcha(val session: NetworkController.SessionMetadata) : RegistrationRoute
|
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
|
@Serializable
|
||||||
data object Profile : RegistrationRoute
|
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> {
|
entry<RegistrationRoute.Restore> {
|
||||||
// TODO: Implement RestoreScreen
|
// TODO: Implement RestoreScreen
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,19 @@ package org.signal.registration
|
|||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
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.AccountAttributes
|
||||||
import org.signal.registration.NetworkController.CreateSessionError
|
import org.signal.registration.NetworkController.CreateSessionError
|
||||||
|
import org.signal.registration.NetworkController.MasterKeyResponse
|
||||||
import org.signal.registration.NetworkController.PreKeyCollection
|
import org.signal.registration.NetworkController.PreKeyCollection
|
||||||
import org.signal.registration.NetworkController.RegisterAccountError
|
import org.signal.registration.NetworkController.RegisterAccountError
|
||||||
import org.signal.registration.NetworkController.RegisterAccountResponse
|
import org.signal.registration.NetworkController.RegisterAccountResponse
|
||||||
import org.signal.registration.NetworkController.RegistrationNetworkResult
|
import org.signal.registration.NetworkController.RegistrationNetworkResult
|
||||||
import org.signal.registration.NetworkController.RequestVerificationCodeError
|
import org.signal.registration.NetworkController.RequestVerificationCodeError
|
||||||
|
import org.signal.registration.NetworkController.RestoreMasterKeyError
|
||||||
import org.signal.registration.NetworkController.SessionMetadata
|
import org.signal.registration.NetworkController.SessionMetadata
|
||||||
|
import org.signal.registration.NetworkController.SvrCredentials
|
||||||
import org.signal.registration.NetworkController.UpdateSessionError
|
import org.signal.registration.NetworkController.UpdateSessionError
|
||||||
import java.util.Locale
|
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.
|
* 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
|
* 1. Generates and stores all required cryptographic key material
|
||||||
* 2. Creates account attributes with registration IDs and capabilities
|
* 2. Creates account attributes with registration IDs and capabilities
|
||||||
* 3. Calls the network controller to register the account
|
* 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 e164 The phone number in E.164 format (used for basic auth)
|
||||||
* @param sessionId The verified session ID from phone number verification
|
* @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
|
* @param skipDeviceTransfer Whether to skip device transfer flow
|
||||||
* @return The registration result containing account information or an error
|
* @return The registration result containing account information or an error
|
||||||
*/
|
*/
|
||||||
suspend fun registerAccount(
|
suspend fun registerAccount(
|
||||||
e164: String,
|
e164: String,
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
|
registrationLock: String? = null,
|
||||||
skipDeviceTransfer: Boolean = true
|
skipDeviceTransfer: Boolean = true
|
||||||
): RegistrationNetworkResult<RegisterAccountResponse, RegisterAccountError> = withContext(Dispatchers.IO) {
|
): RegistrationNetworkResult<RegisterAccountResponse, RegisterAccountError> = withContext(Dispatchers.IO) {
|
||||||
val keyMaterial = storageController.generateAndStoreKeyMaterial()
|
val keyMaterial = storageController.generateAndStoreKeyMaterial()
|
||||||
val fcmToken = networkController.getFcmToken()
|
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(
|
val accountAttributes = AccountAttributes(
|
||||||
signalingKey = null,
|
signalingKey = null,
|
||||||
registrationId = keyMaterial.aciRegistrationId,
|
registrationId = keyMaterial.aciRegistrationId,
|
||||||
voice = true,
|
voice = true,
|
||||||
video = true,
|
video = true,
|
||||||
fetchesMessages = fcmToken == null,
|
fetchesMessages = fcmToken == null,
|
||||||
registrationLock = null,
|
registrationLock = registrationLock,
|
||||||
unidentifiedAccessKey = keyMaterial.unidentifiedAccessKey,
|
unidentifiedAccessKey = keyMaterial.unidentifiedAccessKey,
|
||||||
unrestrictedUnidentifiedAccess = false,
|
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
|
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
|
lastResortKyberPreKey = keyMaterial.pniLastResortKyberPreKey
|
||||||
)
|
)
|
||||||
|
|
||||||
networkController.registerAccount(
|
val result = networkController.registerAccount(
|
||||||
e164 = e164,
|
e164 = e164,
|
||||||
password = keyMaterial.servicePassword,
|
password = keyMaterial.servicePassword,
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
@@ -147,5 +164,19 @@ class RegistrationRepository(val networkController: NetworkController, val stora
|
|||||||
fcmToken = fcmToken,
|
fcmToken = fcmToken,
|
||||||
skipDeviceTransfer = skipDeviceTransfer
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save
|
|||||||
is RegistrationFlowEvent.ResetState -> RegistrationFlowState()
|
is RegistrationFlowEvent.ResetState -> RegistrationFlowState()
|
||||||
is RegistrationFlowEvent.SessionUpdated -> state.copy(sessionMetadata = event.session)
|
is RegistrationFlowEvent.SessionUpdated -> state.copy(sessionMetadata = event.session)
|
||||||
is RegistrationFlowEvent.E164Chosen -> state.copy(sessionE164 = event.e164)
|
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.NavigateToScreen -> applyNavigationToScreenEvent(state, event)
|
||||||
is RegistrationFlowEvent.NavigateBack -> state.copy(backStack = state.backStack.dropLast(1))
|
is RegistrationFlowEvent.NavigateBack -> state.copy(backStack = state.backStack.dropLast(1))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
package org.signal.registration
|
package org.signal.registration
|
||||||
|
|
||||||
import org.signal.core.models.AccountEntropyPool
|
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.IdentityKeyPair
|
||||||
import org.signal.libsignal.protocol.ServiceId
|
|
||||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||||
|
|
||||||
@@ -32,6 +33,11 @@ interface StorageController {
|
|||||||
* @return Data for the existing registration if registered, otherwise null.
|
* @return Data for the existing registration if registered, otherwise null.
|
||||||
*/
|
*/
|
||||||
suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData?
|
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. */
|
/** Unidentified access key (derived from profile key) for sealed sender. */
|
||||||
val unidentifiedAccessKey: ByteArray,
|
val unidentifiedAccessKey: ByteArray,
|
||||||
/** Password for basic auth during registration (18 random bytes, base64 encoded). */
|
/** 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(
|
data class NewRegistrationData(
|
||||||
val e164: String,
|
val e164: String,
|
||||||
val aci: ServiceId.Aci,
|
val aci: ACI,
|
||||||
val pni: ServiceId.Pni,
|
val pni: PNI,
|
||||||
val servicePassword: String,
|
val servicePassword: String,
|
||||||
val aep: AccountEntropyPool
|
val aep: AccountEntropyPool
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PreExistingRegistrationData(
|
data class PreExistingRegistrationData(
|
||||||
val e164: String,
|
val e164: String,
|
||||||
val aci: ServiceId.Aci,
|
val aci: ACI,
|
||||||
val pni: ServiceId.Pni,
|
val pni: PNI,
|
||||||
val servicePassword: String,
|
val servicePassword: String,
|
||||||
val aep: AccountEntropyPool
|
val aep: AccountEntropyPool
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -54,18 +54,28 @@ fun PhoneNumberScreen(
|
|||||||
onEvent: (PhoneNumberEntryScreenEvents) -> Unit,
|
onEvent: (PhoneNumberEntryScreenEvents) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
var simpleErrorMessage: String? by remember { mutableStateOf(null) }
|
||||||
|
|
||||||
LaunchedEffect(state.oneTimeEvent) {
|
LaunchedEffect(state.oneTimeEvent) {
|
||||||
onEvent(PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent)
|
onEvent(PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent)
|
||||||
when (state.oneTimeEvent) {
|
when (state.oneTimeEvent) {
|
||||||
OneTimeEvent.NetworkError -> TODO()
|
OneTimeEvent.NetworkError -> simpleErrorMessage = "Network error"
|
||||||
is OneTimeEvent.RateLimited -> TODO()
|
is OneTimeEvent.RateLimited -> simpleErrorMessage = "Rate limited"
|
||||||
OneTimeEvent.UnknownError -> TODO()
|
OneTimeEvent.UnknownError -> simpleErrorMessage = "Unknown error"
|
||||||
OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> TODO()
|
OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> simpleErrorMessage = "Could not request code with selected transport"
|
||||||
OneTimeEvent.ThirdPartyError -> TODO()
|
OneTimeEvent.ThirdPartyError -> simpleErrorMessage = "Third party error"
|
||||||
null -> Unit
|
null -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
simpleErrorMessage?.let { message ->
|
||||||
|
Dialogs.SimpleMessageDialog(
|
||||||
|
message = message,
|
||||||
|
dismiss = "Ok",
|
||||||
|
onDismiss = { simpleErrorMessage = null }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
ScreenContent(state, onEvent)
|
ScreenContent(state, onEvent)
|
||||||
|
|
||||||
|
|||||||
@@ -48,10 +48,10 @@ class PhoneNumberEntryViewModel(
|
|||||||
|
|
||||||
fun onEvent(event: PhoneNumberEntryScreenEvents) {
|
fun onEvent(event: PhoneNumberEntryScreenEvents) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val stateEMitter: (PhoneNumberEntryState) -> Unit = { state ->
|
val stateEmitter: (PhoneNumberEntryState) -> Unit = { state ->
|
||||||
_state.value = 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))
|
stateEmitter(applyPhoneNumberChanged(state, event.value))
|
||||||
}
|
}
|
||||||
is PhoneNumberEntryScreenEvents.PhoneNumberSubmitted -> {
|
is PhoneNumberEntryScreenEvents.PhoneNumberSubmitted -> {
|
||||||
stateEmitter(state.copy(showFullScreenSpinner = true))
|
var localState = state.copy(showFullScreenSpinner = true)
|
||||||
val resultState = applyPhoneNumberSubmitted(state, parentEventEmitter)
|
stateEmitter(localState)
|
||||||
stateEmitter(resultState.copy(showFullScreenSpinner = false))
|
localState = applyPhoneNumberSubmitted(localState, parentEventEmitter)
|
||||||
|
stateEmitter(localState.copy(showFullScreenSpinner = false))
|
||||||
}
|
}
|
||||||
is PhoneNumberEntryScreenEvents.CountryPicker -> {
|
is PhoneNumberEntryScreenEvents.CountryPicker -> {
|
||||||
state.also { parentEventEmitter.navigateTo(RegistrationRoute.CountryCodePicker) }
|
state.also { parentEventEmitter.navigateTo(RegistrationRoute.CountryCodePicker) }
|
||||||
|
|||||||
@@ -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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = state.errorMessage,
|
text = "Incorrect PIN. ${state.triesRemaining} attempts remaining.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
@@ -205,7 +205,7 @@ private fun PinEntryScreenWithErrorPreview() {
|
|||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
PinEntryScreen(
|
PinEntryScreen(
|
||||||
state = PinEntryState(
|
state = PinEntryState(
|
||||||
errorMessage = "Incorrect PIN. Try again.",
|
triesRemaining = 3,
|
||||||
showNeedHelp = true
|
showNeedHelp = true
|
||||||
),
|
),
|
||||||
onEvent = {}
|
onEvent = {}
|
||||||
|
|||||||
@@ -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!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,18 @@
|
|||||||
|
|
||||||
package org.signal.registration.screens.pinentry
|
package org.signal.registration.screens.pinentry
|
||||||
|
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
data class PinEntryState(
|
data class PinEntryState(
|
||||||
val errorMessage: String? = null,
|
|
||||||
val showNeedHelp: Boolean = false,
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,6 +136,9 @@ class VerificationCodeViewModel(
|
|||||||
}
|
}
|
||||||
is NetworkController.RegistrationNetworkResult.Failure -> {
|
is NetworkController.RegistrationNetworkResult.Failure -> {
|
||||||
when (registerResult.error) {
|
when (registerResult.error) {
|
||||||
|
is NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified -> {
|
||||||
|
TODO()
|
||||||
|
}
|
||||||
is NetworkController.RegisterAccountError.DeviceTransferPossible -> {
|
is NetworkController.RegisterAccountError.DeviceTransferPossible -> {
|
||||||
Log.w(TAG, "[Register] Got told a device transfer is possible. We should never get into this state. Resetting.")
|
Log.w(TAG, "[Register] Got told a device transfer is possible. We should never get into this state. Resetting.")
|
||||||
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
||||||
@@ -143,7 +146,13 @@ class VerificationCodeViewModel(
|
|||||||
}
|
}
|
||||||
is NetworkController.RegisterAccountError.RegistrationLock -> {
|
is NetworkController.RegisterAccountError.RegistrationLock -> {
|
||||||
Log.w(TAG, "[Register] Reglocked.")
|
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 -> {
|
is NetworkController.RegisterAccountError.RateLimited -> {
|
||||||
Log.w(TAG, "[Register] Rate limited.")
|
Log.w(TAG, "[Register] Rate limited.")
|
||||||
|
|||||||
Reference in New Issue
Block a user