Re-organize gradle modules.
118
demo/registration/build.gradle.kts
Normal file
@@ -0,0 +1,118 @@
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("signal-sample-app")
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.kotlinx.serialization)
|
||||
}
|
||||
|
||||
val keystoreProperties: Properties? = loadKeystoreProperties("keystore.debug.properties")
|
||||
|
||||
android {
|
||||
namespace = "org.signal.registration.sample"
|
||||
|
||||
defaultConfig {
|
||||
// IMPORTANT: We use the same package name as the signal staging app so that FCM works.
|
||||
applicationId = "org.thoughtcrime.securesms.staging"
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
keystoreProperties?.let { properties ->
|
||||
signingConfigs.getByName("debug").apply {
|
||||
storeFile = file("${project.rootDir}/${properties.getProperty("storeFile")}")
|
||||
storePassword = properties.getProperty("storePassword")
|
||||
keyAlias = properties.getProperty("keyAlias")
|
||||
keyPassword = properties.getProperty("keyPassword")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
if (keystoreProperties != null) {
|
||||
signingConfig = signingConfigs["debug"]
|
||||
}
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
lintChecks(project(":lintchecks"))
|
||||
|
||||
// Registration library
|
||||
implementation(project(":feature:registration"))
|
||||
|
||||
// Core dependencies
|
||||
implementation(project(":core:ui"))
|
||||
implementation(project(":core:util"))
|
||||
implementation(project(":core:models"))
|
||||
implementation(project(":lib:libsignal-service"))
|
||||
|
||||
// libsignal-protocol for PreKeyCollection types
|
||||
implementation(libs.libsignal.client)
|
||||
|
||||
// Kotlin serialization for JSON parsing
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
// AndroidX
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.sqlite)
|
||||
implementation(libs.androidx.sqlite.framework)
|
||||
|
||||
// Lifecycle
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
|
||||
// Kotlinx Serialization
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
// Navigation 3
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
|
||||
// Permissions
|
||||
implementation(libs.accompanist.permissions)
|
||||
|
||||
// Compose BOM
|
||||
platform(libs.androidx.compose.bom).let { composeBom ->
|
||||
implementation(composeBom)
|
||||
}
|
||||
|
||||
// Compose dependencies
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling.core)
|
||||
|
||||
// Firebase & Play Services
|
||||
implementation(libs.firebase.messaging) {
|
||||
exclude(group = "com.google.firebase", module = "firebase-core")
|
||||
exclude(group = "com.google.firebase", module = "firebase-analytics")
|
||||
exclude(group = "com.google.firebase", module = "firebase-measurement-connector")
|
||||
}
|
||||
implementation(libs.google.play.services.base)
|
||||
implementation(libs.kotlinx.coroutines.play.services)
|
||||
}
|
||||
|
||||
fun loadKeystoreProperties(filename: String): Properties? {
|
||||
val keystorePropertiesFile = file("${project.rootDir}/$filename")
|
||||
|
||||
return if (keystorePropertiesFile.exists()) {
|
||||
val properties = Properties()
|
||||
properties.load(FileInputStream(keystorePropertiesFile))
|
||||
properties
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
37
demo/registration/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".RegistrationApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="Registration Sample"
|
||||
android:theme="@android:style/Theme.Material.NoActionBar"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<!-- Disable Firebase Analytics -->
|
||||
<meta-data android:name="firebase_analytics_collection_deactivated" android:value="true" />
|
||||
<meta-data android:name="firebase_messaging_auto_init_enabled" android:value="false" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- FCM Service for receiving push challenges -->
|
||||
<service
|
||||
android:name=".fcm.FcmReceiveService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalPermissionsApi::class)
|
||||
|
||||
package org.signal.registration.sample
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberDecoratedNavEntries
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.registration.RegistrationDependencies
|
||||
import org.signal.registration.RegistrationNavHost
|
||||
import org.signal.registration.RegistrationRepository
|
||||
import org.signal.registration.sample.debug.NetworkDebugOverlay
|
||||
import org.signal.registration.sample.screens.RegistrationCompleteScreen
|
||||
import org.signal.registration.sample.screens.main.MainScreen
|
||||
import org.signal.registration.sample.screens.main.MainScreenViewModel
|
||||
import org.signal.registration.sample.screens.pinsettings.PinSettingsScreen
|
||||
import org.signal.registration.sample.screens.pinsettings.PinSettingsViewModel
|
||||
|
||||
private const val ANIMATION_DURATION = 300
|
||||
|
||||
/**
|
||||
* Navigation routes for the sample app.
|
||||
*/
|
||||
sealed interface SampleRoute : NavKey {
|
||||
@Serializable
|
||||
data object Main : SampleRoute
|
||||
|
||||
@Serializable
|
||||
data object Registration : SampleRoute
|
||||
|
||||
@Serializable
|
||||
data object RegistrationComplete : SampleRoute
|
||||
|
||||
@Serializable
|
||||
data object PinSettings : SampleRoute
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample app activity that launches the registration flow for testing.
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
SignalTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
AppScreen(RegistrationDependencies.get())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppScreen(registrationDependencies: RegistrationDependencies) {
|
||||
val backStack = rememberNavBackStack(SampleRoute.Main)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
SampleNavHost(
|
||||
backStack = backStack,
|
||||
registrationDependencies = registrationDependencies,
|
||||
onStartOver = {
|
||||
backStack.clear()
|
||||
backStack.add(SampleRoute.Main)
|
||||
}
|
||||
)
|
||||
|
||||
// Debug overlay for forcing network responses
|
||||
NetworkDebugOverlay(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SampleNavHost(
|
||||
onStartOver: () -> Unit,
|
||||
registrationDependencies: RegistrationDependencies,
|
||||
backStack: NavBackStack<NavKey>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val registrationRepository = remember {
|
||||
RegistrationRepository(
|
||||
networkController = registrationDependencies.networkController,
|
||||
storageController = registrationDependencies.storageController
|
||||
)
|
||||
}
|
||||
|
||||
val entryProvider: (NavKey) -> NavEntry<NavKey> = entryProvider {
|
||||
entry<SampleRoute.Main> {
|
||||
val viewModel: MainScreenViewModel = viewModel(
|
||||
factory = MainScreenViewModel.Factory(
|
||||
storageController = registrationDependencies.storageController,
|
||||
onLaunchRegistration = { backStack.add(SampleRoute.Registration) },
|
||||
onOpenPinSettings = { backStack.add(SampleRoute.PinSettings) }
|
||||
)
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
LifecycleResumeEffect(Unit) {
|
||||
viewModel.refreshData()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
MainScreen(
|
||||
state = state,
|
||||
onEvent = { viewModel.onEvent(it) }
|
||||
)
|
||||
}
|
||||
|
||||
entry<SampleRoute.Registration> {
|
||||
RegistrationNavHost(
|
||||
registrationRepository,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onRegistrationComplete = {
|
||||
backStack.add(SampleRoute.RegistrationComplete)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
entry<SampleRoute.RegistrationComplete> {
|
||||
RegistrationCompleteScreen(onStartOver = onStartOver)
|
||||
}
|
||||
|
||||
entry<SampleRoute.PinSettings>(
|
||||
metadata = BottomSheetTransitionSpec
|
||||
) {
|
||||
val viewModel: PinSettingsViewModel = viewModel(
|
||||
factory = PinSettingsViewModel.Factory(
|
||||
networkController = registrationDependencies.networkController,
|
||||
onBack = { backStack.removeLastOrNull() }
|
||||
)
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
PinSettingsScreen(
|
||||
state = state,
|
||||
onEvent = { viewModel.onEvent(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val decorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator<NavKey>()
|
||||
)
|
||||
|
||||
val entries = rememberDecoratedNavEntries(
|
||||
backStack = backStack,
|
||||
entryDecorators = decorators,
|
||||
entryProvider = entryProvider
|
||||
)
|
||||
|
||||
NavDisplay(
|
||||
entries = entries,
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
modifier = modifier,
|
||||
transitionSpec = {
|
||||
// Default: slide in from right, previous screen shrinks back
|
||||
(
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { it },
|
||||
animationSpec = tween(ANIMATION_DURATION)
|
||||
) + fadeIn(animationSpec = tween(ANIMATION_DURATION))
|
||||
) togetherWith (
|
||||
fadeOut(animationSpec = tween(ANIMATION_DURATION)) +
|
||||
scaleOut(
|
||||
targetScale = 0.9f,
|
||||
animationSpec = tween(ANIMATION_DURATION)
|
||||
)
|
||||
)
|
||||
},
|
||||
popTransitionSpec = {
|
||||
// Default pop: scale up from background, current slides out right
|
||||
(
|
||||
fadeIn(animationSpec = tween(ANIMATION_DURATION)) +
|
||||
scaleIn(
|
||||
initialScale = 0.9f,
|
||||
animationSpec = tween(ANIMATION_DURATION)
|
||||
)
|
||||
) togetherWith (
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { it },
|
||||
animationSpec = tween(ANIMATION_DURATION)
|
||||
) + fadeOut(animationSpec = tween(ANIMATION_DURATION))
|
||||
)
|
||||
},
|
||||
predictivePopTransitionSpec = {
|
||||
(
|
||||
fadeIn(animationSpec = tween(ANIMATION_DURATION)) +
|
||||
scaleIn(
|
||||
initialScale = 0.9f,
|
||||
animationSpec = tween(ANIMATION_DURATION)
|
||||
)
|
||||
) togetherWith (
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { it },
|
||||
animationSpec = tween(ANIMATION_DURATION)
|
||||
) + fadeOut(animationSpec = tween(ANIMATION_DURATION))
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition spec for bottom sheet style screens that slide up from the bottom.
|
||||
*/
|
||||
private val BottomSheetTransitionSpec = NavDisplay.transitionSpec {
|
||||
(
|
||||
slideInVertically(
|
||||
initialOffsetY = { it },
|
||||
animationSpec = tween(ANIMATION_DURATION)
|
||||
) + fadeIn(animationSpec = tween(ANIMATION_DURATION))
|
||||
) togetherWith ExitTransition.KeepUntilTransitionsFinished
|
||||
} + NavDisplay.popTransitionSpec {
|
||||
EnterTransition.None togetherWith (
|
||||
slideOutVertically(
|
||||
targetOffsetY = { it },
|
||||
animationSpec = tween(ANIMATION_DURATION)
|
||||
) + fadeOut(animationSpec = tween(ANIMATION_DURATION))
|
||||
)
|
||||
} + NavDisplay.predictivePopTransitionSpec {
|
||||
EnterTransition.None togetherWith (
|
||||
slideOutVertically(
|
||||
targetOffsetY = { it },
|
||||
animationSpec = tween(ANIMATION_DURATION)
|
||||
) + fadeOut(animationSpec = tween(ANIMATION_DURATION))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.models.ServiceId.PNI
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.logging.AndroidLogger
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.registration.RegistrationDependencies
|
||||
import org.signal.registration.sample.debug.DebugNetworkController
|
||||
import org.signal.registration.sample.dependencies.RealNetworkController
|
||||
import org.signal.registration.sample.dependencies.RealStorageController
|
||||
import org.signal.registration.sample.storage.RegistrationPreferences
|
||||
import org.whispersystems.signalservice.api.push.TrustStore
|
||||
import org.whispersystems.signalservice.api.util.CredentialsProvider
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalSvr2Url
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||
import java.io.InputStream
|
||||
import java.util.Optional
|
||||
|
||||
class RegistrationApplication : Application() {
|
||||
|
||||
companion object {
|
||||
// Staging SVR2 mrEnclave value
|
||||
private const val SVR2_MRENCLAVE = "a75542d82da9f6914a1e31f8a7407053b99cc99a0e7291d8fbd394253e19b036"
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
Log.initialize(AndroidLogger)
|
||||
|
||||
RegistrationPreferences.init(this)
|
||||
|
||||
val trustStore = SampleTrustStore()
|
||||
val configuration = createServiceConfiguration(trustStore)
|
||||
val pushServiceSocket = createPushServiceSocket(configuration)
|
||||
val realNetworkController = RealNetworkController(this, pushServiceSocket, configuration, SVR2_MRENCLAVE)
|
||||
val networkController = DebugNetworkController(realNetworkController)
|
||||
val storageController = RealStorageController(this)
|
||||
|
||||
RegistrationDependencies.provide(
|
||||
RegistrationDependencies(
|
||||
networkController = networkController,
|
||||
storageController = storageController
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createPushServiceSocket(configuration: SignalServiceConfiguration): PushServiceSocket {
|
||||
val credentialsProvider = NoopCredentialsProvider()
|
||||
val signalAgent = "Signal-Android/${BuildConfig.VERSION_NAME} Android/${Build.VERSION.SDK_INT}"
|
||||
|
||||
return PushServiceSocket(
|
||||
configuration,
|
||||
credentialsProvider,
|
||||
signalAgent,
|
||||
true // automaticNetworkRetry
|
||||
)
|
||||
}
|
||||
|
||||
private fun createServiceConfiguration(trustStore: TrustStore): SignalServiceConfiguration {
|
||||
return SignalServiceConfiguration(
|
||||
signalServiceUrls = arrayOf(SignalServiceUrl("https://chat.staging.signal.org", trustStore)),
|
||||
signalCdnUrlMap = mapOf(
|
||||
0 to arrayOf(SignalCdnUrl("https://cdn-staging.signal.org", trustStore)),
|
||||
2 to arrayOf(SignalCdnUrl("https://cdn2-staging.signal.org", trustStore)),
|
||||
3 to arrayOf(SignalCdnUrl("https://cdn3-staging.signal.org", trustStore))
|
||||
),
|
||||
signalStorageUrls = arrayOf(SignalStorageUrl("https://storage-staging.signal.org", trustStore)),
|
||||
signalCdsiUrls = arrayOf(SignalCdsiUrl("https://cdsi.staging.signal.org", trustStore)),
|
||||
signalSvr2Urls = arrayOf(SignalSvr2Url("https://svr2.staging.signal.org", trustStore)),
|
||||
networkInterceptors = emptyList(),
|
||||
dns = Optional.empty(),
|
||||
signalProxy = Optional.empty(),
|
||||
systemHttpProxy = Optional.empty(),
|
||||
zkGroupServerPublicParams = Base64.decode("ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ=="),
|
||||
genericServerPublicParams = Base64.decode("AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N"),
|
||||
backupServerPublicParams = Base64.decode("AHYrGb9IfugAAJiPKp+mdXUx+OL9zBolPYHYQz6GI1gWjpEu5me3zVNSvmYY4zWboZHif+HG1sDHSuvwFd0QszSwuSF4X4kRP3fJREdTZ5MCR0n55zUppTwfHRW2S4sdQ0JGz7YDQIJCufYSKh0pGNEHL6hv79Agrdnr4momr3oXdnkpVBIp3HWAQ6IbXQVSG18X36GaicI1vdT0UFmTwU2KTneluC2eyL9c5ff8PcmiS+YcLzh0OKYQXB5ZfQ06d6DiINvDQLy75zcfUOniLAj0lGJiHxGczin/RXisKSR8"),
|
||||
censored = false
|
||||
)
|
||||
}
|
||||
|
||||
private inner class SampleTrustStore : TrustStore {
|
||||
override fun getKeyStoreInputStream(): InputStream {
|
||||
return resources.openRawResource(R.raw.whisper)
|
||||
}
|
||||
|
||||
override fun getKeyStorePassword(): String {
|
||||
return "whisper"
|
||||
}
|
||||
}
|
||||
|
||||
private class NoopCredentialsProvider : CredentialsProvider {
|
||||
override fun getAci(): ACI? = null
|
||||
override fun getPni(): PNI? = null
|
||||
override fun getE164(): String? = null
|
||||
override fun getDeviceId(): Int = 1
|
||||
override fun getPassword(): String? = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.debug
|
||||
|
||||
import org.signal.core.models.MasterKey
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.registration.NetworkController
|
||||
import org.signal.registration.NetworkController.AccountAttributes
|
||||
import org.signal.registration.NetworkController.BackupMasterKeyError
|
||||
import org.signal.registration.NetworkController.CreateSessionError
|
||||
import org.signal.registration.NetworkController.GetSessionStatusError
|
||||
import org.signal.registration.NetworkController.GetSvrCredentialsError
|
||||
import org.signal.registration.NetworkController.MasterKeyResponse
|
||||
import org.signal.registration.NetworkController.PreKeyCollection
|
||||
import org.signal.registration.NetworkController.RegisterAccountError
|
||||
import org.signal.registration.NetworkController.RegisterAccountResponse
|
||||
import org.signal.registration.NetworkController.RegistrationNetworkResult
|
||||
import org.signal.registration.NetworkController.RequestVerificationCodeError
|
||||
import org.signal.registration.NetworkController.RestoreMasterKeyError
|
||||
import org.signal.registration.NetworkController.SessionMetadata
|
||||
import org.signal.registration.NetworkController.SetAccountAttributesError
|
||||
import org.signal.registration.NetworkController.SetRegistrationLockError
|
||||
import org.signal.registration.NetworkController.SubmitVerificationCodeError
|
||||
import org.signal.registration.NetworkController.SvrCredentials
|
||||
import org.signal.registration.NetworkController.UpdateSessionError
|
||||
import org.signal.registration.NetworkController.VerificationCodeTransport
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Debug wrapper for NetworkController that allows forcing specific responses.
|
||||
*
|
||||
* When an override is set for a method via [NetworkDebugState], this controller
|
||||
* returns the forced result instead of calling the delegate.
|
||||
*
|
||||
* This is useful for testing error handling, edge cases, and UI states without
|
||||
* needing a real backend connection.
|
||||
*/
|
||||
class DebugNetworkController(
|
||||
private val delegate: NetworkController
|
||||
) : NetworkController {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(DebugNetworkController::class)
|
||||
}
|
||||
|
||||
override suspend fun createSession(
|
||||
e164: String,
|
||||
fcmToken: String?,
|
||||
mcc: String?,
|
||||
mnc: String?
|
||||
): RegistrationNetworkResult<SessionMetadata, CreateSessionError> {
|
||||
NetworkDebugState.getOverride<RegistrationNetworkResult<SessionMetadata, CreateSessionError>>("createSession")?.let {
|
||||
Log.d(TAG, "[createSession] Returning debug override")
|
||||
return it
|
||||
}
|
||||
return delegate.createSession(e164, fcmToken, mcc, mnc)
|
||||
}
|
||||
|
||||
override suspend fun getSession(sessionId: String): RegistrationNetworkResult<SessionMetadata, GetSessionStatusError> {
|
||||
NetworkDebugState.getOverride<RegistrationNetworkResult<SessionMetadata, GetSessionStatusError>>("getSession")?.let {
|
||||
Log.d(TAG, "[getSession] Returning debug override")
|
||||
return it
|
||||
}
|
||||
return delegate.getSession(sessionId)
|
||||
}
|
||||
|
||||
override suspend fun updateSession(
|
||||
sessionId: String?,
|
||||
pushChallengeToken: String?,
|
||||
captchaToken: String?
|
||||
): RegistrationNetworkResult<SessionMetadata, UpdateSessionError> {
|
||||
NetworkDebugState.getOverride<RegistrationNetworkResult<SessionMetadata, UpdateSessionError>>("updateSession")?.let {
|
||||
Log.d(TAG, "[updateSession] Returning debug override")
|
||||
return it
|
||||
}
|
||||
return delegate.updateSession(sessionId, pushChallengeToken, captchaToken)
|
||||
}
|
||||
|
||||
override suspend fun requestVerificationCode(
|
||||
sessionId: String,
|
||||
locale: Locale?,
|
||||
androidSmsRetrieverSupported: Boolean,
|
||||
transport: VerificationCodeTransport
|
||||
): RegistrationNetworkResult<SessionMetadata, RequestVerificationCodeError> {
|
||||
NetworkDebugState.getOverride<RegistrationNetworkResult<SessionMetadata, RequestVerificationCodeError>>("requestVerificationCode")?.let {
|
||||
Log.d(TAG, "[requestVerificationCode] Returning debug override")
|
||||
return it
|
||||
}
|
||||
return delegate.requestVerificationCode(sessionId, locale, androidSmsRetrieverSupported, transport)
|
||||
}
|
||||
|
||||
override suspend fun submitVerificationCode(
|
||||
sessionId: String,
|
||||
verificationCode: String
|
||||
): RegistrationNetworkResult<SessionMetadata, SubmitVerificationCodeError> {
|
||||
NetworkDebugState.getOverride<RegistrationNetworkResult<SessionMetadata, SubmitVerificationCodeError>>("submitVerificationCode")?.let {
|
||||
Log.d(TAG, "[submitVerificationCode] Returning debug override")
|
||||
return it
|
||||
}
|
||||
return delegate.submitVerificationCode(sessionId, verificationCode)
|
||||
}
|
||||
|
||||
override suspend fun registerAccount(
|
||||
e164: String,
|
||||
password: String,
|
||||
sessionId: String?,
|
||||
recoveryPassword: String?,
|
||||
attributes: AccountAttributes,
|
||||
aciPreKeys: PreKeyCollection,
|
||||
pniPreKeys: PreKeyCollection,
|
||||
fcmToken: String?,
|
||||
skipDeviceTransfer: Boolean
|
||||
): RegistrationNetworkResult<RegisterAccountResponse, RegisterAccountError> {
|
||||
NetworkDebugState.getOverride<RegistrationNetworkResult<RegisterAccountResponse, RegisterAccountError>>("registerAccount")?.let {
|
||||
Log.d(TAG, "[registerAccount] Returning debug override")
|
||||
return it
|
||||
}
|
||||
return delegate.registerAccount(e164, password, sessionId, recoveryPassword, attributes, aciPreKeys, pniPreKeys, fcmToken, skipDeviceTransfer)
|
||||
}
|
||||
|
||||
override suspend fun getFcmToken(): String? {
|
||||
// No override support for simple value methods
|
||||
return delegate.getFcmToken()
|
||||
}
|
||||
|
||||
override suspend fun awaitPushChallengeToken(): String? {
|
||||
// No override support for simple value methods
|
||||
return delegate.awaitPushChallengeToken()
|
||||
}
|
||||
|
||||
override fun getCaptchaUrl(): String {
|
||||
// No override support for simple value methods
|
||||
return delegate.getCaptchaUrl()
|
||||
}
|
||||
|
||||
override suspend fun restoreMasterKeyFromSvr(
|
||||
svr2Credentials: SvrCredentials,
|
||||
pin: String
|
||||
): RegistrationNetworkResult<MasterKeyResponse, RestoreMasterKeyError> {
|
||||
NetworkDebugState.getOverride<RegistrationNetworkResult<MasterKeyResponse, RestoreMasterKeyError>>("restoreMasterKeyFromSvr")?.let {
|
||||
Log.d(TAG, "[restoreMasterKeyFromSvr] Returning debug override")
|
||||
return it
|
||||
}
|
||||
return delegate.restoreMasterKeyFromSvr(svr2Credentials, pin)
|
||||
}
|
||||
|
||||
override suspend fun setPinAndMasterKeyOnSvr(
|
||||
pin: String,
|
||||
masterKey: MasterKey
|
||||
): RegistrationNetworkResult<Unit, BackupMasterKeyError> {
|
||||
NetworkDebugState.getOverride<RegistrationNetworkResult<Unit, BackupMasterKeyError>>("setPinAndMasterKeyOnSvr")?.let {
|
||||
Log.d(TAG, "[setPinAndMasterKeyOnSvr] Returning debug override")
|
||||
return it
|
||||
}
|
||||
return delegate.setPinAndMasterKeyOnSvr(pin, masterKey)
|
||||
}
|
||||
|
||||
override suspend fun enableRegistrationLock(): RegistrationNetworkResult<Unit, SetRegistrationLockError> {
|
||||
NetworkDebugState.getOverride<RegistrationNetworkResult<Unit, SetRegistrationLockError>>("enableRegistrationLock")?.let {
|
||||
Log.d(TAG, "[enableRegistrationLock] Returning debug override")
|
||||
return it
|
||||
}
|
||||
return delegate.enableRegistrationLock()
|
||||
}
|
||||
|
||||
override suspend fun disableRegistrationLock(): RegistrationNetworkResult<Unit, SetRegistrationLockError> {
|
||||
NetworkDebugState.getOverride<RegistrationNetworkResult<Unit, SetRegistrationLockError>>("disableRegistrationLock")?.let {
|
||||
Log.d(TAG, "[disableRegistrationLock] Returning debug override")
|
||||
return it
|
||||
}
|
||||
return delegate.disableRegistrationLock()
|
||||
}
|
||||
|
||||
override suspend fun setAccountAttributes(attributes: AccountAttributes): RegistrationNetworkResult<Unit, SetAccountAttributesError> {
|
||||
NetworkDebugState.getOverride<RegistrationNetworkResult<Unit, SetAccountAttributesError>>("setAccountAttributes")?.let {
|
||||
Log.d(TAG, "[setAccountAttributes] Returning debug override")
|
||||
return it
|
||||
}
|
||||
return delegate.setAccountAttributes(attributes)
|
||||
}
|
||||
|
||||
override suspend fun getSvrCredentials(): RegistrationNetworkResult<SvrCredentials, GetSvrCredentialsError> {
|
||||
NetworkDebugState.getOverride<RegistrationNetworkResult<SvrCredentials, GetSvrCredentialsError>>("getSvrCredentials")?.let {
|
||||
Log.d(TAG, "[getSvrCredentials] Returning debug override")
|
||||
return it
|
||||
}
|
||||
return delegate.getSvrCredentials()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.debug
|
||||
|
||||
import org.signal.registration.NetworkController
|
||||
import org.signal.registration.NetworkController.RegistrationLockResponse
|
||||
import org.signal.registration.NetworkController.RegistrationNetworkResult
|
||||
import org.signal.registration.NetworkController.SessionMetadata
|
||||
import org.signal.registration.NetworkController.SvrCredentials
|
||||
import org.signal.registration.NetworkController.ThirdPartyServiceErrorResponse
|
||||
import java.io.IOException
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.KParameter
|
||||
import kotlin.reflect.KType
|
||||
import kotlin.reflect.full.memberFunctions
|
||||
import kotlin.reflect.full.primaryConstructor
|
||||
import kotlin.reflect.full.valueParameters
|
||||
import kotlin.reflect.jvm.jvmErasure
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Mock data factory for debug overrides.
|
||||
* Uses reflection to automatically discover NetworkController methods and their error types.
|
||||
* Only provides failure/error options - success cases are not overridable.
|
||||
*/
|
||||
object DebugNetworkMockData {
|
||||
|
||||
// ============================================
|
||||
// Mock data for error type constructor params
|
||||
// ============================================
|
||||
|
||||
private val mockSessionMetadata = SessionMetadata(
|
||||
id = "mock-session-id-12345",
|
||||
nextSms = System.currentTimeMillis() + 60_000,
|
||||
nextCall = System.currentTimeMillis() + 120_000,
|
||||
nextVerificationAttempt = System.currentTimeMillis() + 30_000,
|
||||
allowedToRequestCode = true,
|
||||
requestedInformation = emptyList(),
|
||||
verified = false
|
||||
)
|
||||
|
||||
private val mockSvrCredentials = SvrCredentials(
|
||||
username = "mock-svr-username",
|
||||
password = "mock-svr-password"
|
||||
)
|
||||
|
||||
private val mockRegistrationLockResponse = RegistrationLockResponse(
|
||||
timeRemaining = 86400000L,
|
||||
svr2Credentials = mockSvrCredentials
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Data Classes
|
||||
// ============================================
|
||||
|
||||
data class ResultOption(
|
||||
val name: String,
|
||||
val displayName: String,
|
||||
val createResult: () -> Any
|
||||
)
|
||||
|
||||
data class MethodOverrideInfo(
|
||||
val methodName: String,
|
||||
val displayName: String,
|
||||
val options: List<ResultOption>
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Reflection-based Method Discovery
|
||||
// ============================================
|
||||
|
||||
val allMethods: List<MethodOverrideInfo> by lazy {
|
||||
discoverMethods()
|
||||
}
|
||||
|
||||
private fun discoverMethods(): List<MethodOverrideInfo> {
|
||||
return NetworkController::class.memberFunctions
|
||||
.filter { it.returnType.isRegistrationNetworkResult() }
|
||||
.map { function ->
|
||||
val methodName = function.name
|
||||
val (_, errorType) = extractResultTypes(function.returnType)
|
||||
val options = buildOptionsForMethod(methodName, errorType)
|
||||
MethodOverrideInfo(methodName, methodName, options)
|
||||
}
|
||||
.sortedBy { it.methodName }
|
||||
}
|
||||
|
||||
private fun KType.isRegistrationNetworkResult(): Boolean {
|
||||
return this.jvmErasure == RegistrationNetworkResult::class
|
||||
}
|
||||
|
||||
private fun extractResultTypes(returnType: KType): Pair<KClass<*>?, KClass<*>?> {
|
||||
val typeArgs = returnType.arguments
|
||||
val successType = typeArgs.getOrNull(0)?.type?.jvmErasure
|
||||
val errorType = typeArgs.getOrNull(1)?.type?.jvmErasure
|
||||
return successType to errorType
|
||||
}
|
||||
|
||||
private fun buildOptionsForMethod(
|
||||
methodName: String,
|
||||
errorType: KClass<*>?
|
||||
): List<ResultOption> {
|
||||
val options = mutableListOf<ResultOption>()
|
||||
|
||||
// Always add "Unset" first
|
||||
options.add(ResultOption("unset", "Unset") { Unit })
|
||||
|
||||
// Add error options from sealed subclasses
|
||||
if (errorType != null) {
|
||||
val errorOptions = generateErrorOptions(errorType)
|
||||
options.addAll(errorOptions)
|
||||
}
|
||||
|
||||
// Always add NetworkError and ApplicationError
|
||||
options.add(
|
||||
ResultOption("network_error", "NetworkError") {
|
||||
RegistrationNetworkResult.NetworkError(IOException("Mock network error"))
|
||||
}
|
||||
)
|
||||
options.add(
|
||||
ResultOption("application_error", "ApplicationError") {
|
||||
RegistrationNetworkResult.ApplicationError(RuntimeException("Mock application error"))
|
||||
}
|
||||
)
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
private fun generateErrorOptions(errorType: KClass<*>): List<ResultOption> {
|
||||
return errorType.sealedSubclasses.mapNotNull { subclass ->
|
||||
val instance = createMockInstance(subclass)
|
||||
if (instance != null) {
|
||||
val name = subclass.simpleName?.toSnakeCase() ?: return@mapNotNull null
|
||||
val displayName = subclass.simpleName ?: return@mapNotNull null
|
||||
ResultOption(name, displayName) {
|
||||
RegistrationNetworkResult.Failure(instance)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMockInstance(klass: KClass<*>): Any? {
|
||||
// Try object instance first (for data objects)
|
||||
klass.objectInstance?.let { return it }
|
||||
|
||||
// Try primary constructor
|
||||
val constructor = klass.primaryConstructor ?: return null
|
||||
|
||||
return try {
|
||||
if (constructor.parameters.isEmpty()) {
|
||||
constructor.call()
|
||||
} else {
|
||||
val args = constructor.valueParameters.associateWith { param ->
|
||||
createMockValueForParameter(param)
|
||||
}
|
||||
constructor.callBy(args)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMockValueForParameter(param: KParameter): Any? {
|
||||
val type = param.type.jvmErasure
|
||||
|
||||
return when {
|
||||
type == String::class -> "Mock ${param.name}"
|
||||
type == Int::class -> 3
|
||||
type == Long::class -> 60000L
|
||||
type == Boolean::class -> false
|
||||
type == Duration::class -> 60.seconds
|
||||
type == SessionMetadata::class -> mockSessionMetadata
|
||||
type == SvrCredentials::class -> mockSvrCredentials
|
||||
type == RegistrationLockResponse::class -> mockRegistrationLockResponse
|
||||
type == ThirdPartyServiceErrorResponse::class -> ThirdPartyServiceErrorResponse(
|
||||
reason = "Mock third party error",
|
||||
permanentFailure = false
|
||||
)
|
||||
param.type.isMarkedNullable -> null
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toSnakeCase(): String {
|
||||
return this.fold(StringBuilder()) { acc, c ->
|
||||
if (c.isUpperCase() && acc.isNotEmpty()) {
|
||||
acc.append('_')
|
||||
}
|
||||
acc.append(c.lowercase())
|
||||
}.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.debug
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Debug overlay that provides a draggable floating button to access network override controls.
|
||||
* When tapped, opens a dialog allowing developers to force specific network responses.
|
||||
*/
|
||||
@Composable
|
||||
fun NetworkDebugOverlay(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var dragOffset by remember { mutableStateOf(Offset.Zero) }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
DebugFloatingButton(
|
||||
onClick = { showDialog = true },
|
||||
dragOffset = dragOffset,
|
||||
onDrag = { delta -> dragOffset += delta },
|
||||
modifier = Modifier.align(Alignment.BottomEnd)
|
||||
)
|
||||
|
||||
if (showDialog) {
|
||||
NetworkDebugDialog(
|
||||
onDismiss = { showDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DebugFloatingButton(
|
||||
onClick: () -> Unit,
|
||||
dragOffset: Offset,
|
||||
onDrag: (Offset) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val overrideSelections by NetworkDebugState.overrideSelections.collectAsState()
|
||||
val hasActiveOverrides = overrideSelections.isNotEmpty()
|
||||
|
||||
FilledTonalIconButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.offset { IntOffset(dragOffset.x.roundToInt(), dragOffset.y.roundToInt()) }
|
||||
.pointerInput(Unit) {
|
||||
detectDragGestures { change, dragAmount ->
|
||||
change.consume()
|
||||
onDrag(dragAmount)
|
||||
}
|
||||
}
|
||||
.padding(16.dp)
|
||||
.size(56.dp),
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
) {
|
||||
Box {
|
||||
Text(
|
||||
text = "\uD83D\uDD27", // Wrench emoji
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
if (hasActiveOverrides) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.error)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NetworkDebugDialog(
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.95f)
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Network Debug",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Force specific responses from NetworkController methods",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = false),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(DebugNetworkMockData.allMethods) { methodInfo ->
|
||||
MethodOverrideRow(methodInfo = methodInfo)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { NetworkDebugState.clearAllOverrides() }
|
||||
) {
|
||||
Text("Clear All")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MethodOverrideRow(
|
||||
methodInfo: DebugNetworkMockData.MethodOverrideInfo
|
||||
) {
|
||||
val overrideSelections by NetworkDebugState.overrideSelections.collectAsState()
|
||||
val currentSelection = overrideSelections[methodInfo.methodName] ?: "unset"
|
||||
val currentOption = methodInfo.options.find { it.name == currentSelection }
|
||||
?: methodInfo.options.first()
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = methodInfo.displayName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Box {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
if (currentSelection != "unset") {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
)
|
||||
.clickable { expanded = true }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = currentOption.displayName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (currentSelection != "unset") {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "▼",
|
||||
color = if (currentSelection != "unset") {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.fillMaxWidth(0.8f)
|
||||
) {
|
||||
methodInfo.options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = option.displayName,
|
||||
fontWeight = if (option.name == currentSelection) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
if (option.name == "unset") {
|
||||
NetworkDebugState.clearOverride(methodInfo.methodName)
|
||||
} else {
|
||||
NetworkDebugState.setOverride(
|
||||
methodName = methodInfo.methodName,
|
||||
optionName = option.name,
|
||||
result = option.createResult()
|
||||
)
|
||||
}
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun NetworkDebugOverlayPreview() {
|
||||
Previews.Preview {
|
||||
NetworkDebugOverlay(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun NetworkDebugDialogPreview() {
|
||||
Previews.Preview {
|
||||
NetworkDebugDialog(
|
||||
onDismiss = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MethodOverrideRowPreview() {
|
||||
Previews.Preview {
|
||||
MethodOverrideRow(
|
||||
methodInfo = DebugNetworkMockData.allMethods.first()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.debug
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* Singleton state manager for network debug overrides.
|
||||
* Tracks which methods have forced responses set.
|
||||
*/
|
||||
object NetworkDebugState {
|
||||
|
||||
/**
|
||||
* Map of method name to the selected option name (e.g., "createSession" -> "success")
|
||||
* A value of "unset" or absence means no override is active.
|
||||
*/
|
||||
private val _overrideSelections = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val overrideSelections: StateFlow<Map<String, String>> = _overrideSelections.asStateFlow()
|
||||
|
||||
/**
|
||||
* Map of method name to the actual result object to return.
|
||||
* This is populated when setOverride is called.
|
||||
*/
|
||||
private val _overrideResults = MutableStateFlow<Map<String, Any>>(emptyMap())
|
||||
val overrideResults: StateFlow<Map<String, Any>> = _overrideResults.asStateFlow()
|
||||
|
||||
/**
|
||||
* Set an override for a specific method.
|
||||
*
|
||||
* @param methodName The name of the NetworkController method
|
||||
* @param optionName The name of the selected option (e.g., "success", "rate_limited")
|
||||
* @param result The actual result object to return, or null to clear the override
|
||||
*/
|
||||
fun setOverride(methodName: String, optionName: String, result: Any?) {
|
||||
if (optionName == "unset" || result == null || result == Unit) {
|
||||
clearOverride(methodName)
|
||||
} else {
|
||||
_overrideSelections.update { it + (methodName to optionName) }
|
||||
_overrideResults.update { it + (methodName to result) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the override for a specific method.
|
||||
*/
|
||||
fun clearOverride(methodName: String) {
|
||||
_overrideSelections.update { it - methodName }
|
||||
_overrideResults.update { it - methodName }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all overrides.
|
||||
*/
|
||||
fun clearAllOverrides() {
|
||||
_overrideSelections.value = emptyMap()
|
||||
_overrideResults.value = emptyMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current override result for a method, if any.
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T> getOverride(methodName: String): T? {
|
||||
return _overrideResults.value[methodName] as? T
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a method has an active override.
|
||||
*/
|
||||
fun hasOverride(methodName: String): Boolean {
|
||||
return _overrideResults.value.containsKey(methodName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected option name for a method.
|
||||
*/
|
||||
fun getSelectedOption(methodName: String): String {
|
||||
return _overrideSelections.value[methodName] ?: "unset"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,724 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.dependencies
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import org.signal.core.models.MasterKey
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.net.Network
|
||||
import org.signal.registration.NetworkController
|
||||
import org.signal.registration.NetworkController.AccountAttributes
|
||||
import org.signal.registration.NetworkController.CreateSessionError
|
||||
import org.signal.registration.NetworkController.GetSessionStatusError
|
||||
import org.signal.registration.NetworkController.PreKeyCollection
|
||||
import org.signal.registration.NetworkController.RegisterAccountError
|
||||
import org.signal.registration.NetworkController.RegisterAccountResponse
|
||||
import org.signal.registration.NetworkController.RegistrationLockResponse
|
||||
import org.signal.registration.NetworkController.RegistrationNetworkResult
|
||||
import org.signal.registration.NetworkController.RequestVerificationCodeError
|
||||
import org.signal.registration.NetworkController.SessionMetadata
|
||||
import org.signal.registration.NetworkController.SubmitVerificationCodeError
|
||||
import org.signal.registration.NetworkController.ThirdPartyServiceErrorResponse
|
||||
import org.signal.registration.NetworkController.UpdateSessionError
|
||||
import org.signal.registration.NetworkController.VerificationCodeTransport
|
||||
import org.signal.registration.sample.fcm.FcmUtil
|
||||
import org.signal.registration.sample.fcm.PushChallengeReceiver
|
||||
import org.signal.registration.sample.storage.RegistrationPreferences
|
||||
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse
|
||||
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.RestoreResponse
|
||||
import org.whispersystems.signalservice.api.svr.SecureValueRecoveryV2
|
||||
import org.whispersystems.signalservice.api.util.SleepTimer
|
||||
import org.whispersystems.signalservice.api.websocket.HealthMonitor
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketFactory
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||
import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider
|
||||
import org.whispersystems.signalservice.internal.websocket.LibSignalChatConnection
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import org.whispersystems.signalservice.api.account.AccountAttributes as ServiceAccountAttributes
|
||||
import org.whispersystems.signalservice.api.account.PreKeyCollection as ServicePreKeyCollection
|
||||
|
||||
class RealNetworkController(
|
||||
private val context: android.content.Context,
|
||||
private val pushServiceSocket: PushServiceSocket,
|
||||
private val serviceConfiguration: SignalServiceConfiguration,
|
||||
private val svr2MrEnclave: String
|
||||
) : NetworkController {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(RealNetworkController::class)
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val okHttpClient: okhttp3.OkHttpClient by lazy {
|
||||
val trustStore = serviceConfiguration.signalServiceUrls[0].trustStore
|
||||
val keyStore = java.security.KeyStore.getInstance(java.security.KeyStore.getDefaultType())
|
||||
keyStore.load(trustStore.keyStoreInputStream, trustStore.keyStorePassword.toCharArray())
|
||||
|
||||
val tmf = javax.net.ssl.TrustManagerFactory.getInstance(javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm())
|
||||
tmf.init(keyStore)
|
||||
|
||||
val sslContext = javax.net.ssl.SSLContext.getInstance("TLS")
|
||||
sslContext.init(null, tmf.trustManagers, null)
|
||||
|
||||
val trustManager = tmf.trustManagers[0] as javax.net.ssl.X509TrustManager
|
||||
|
||||
okhttp3.OkHttpClient.Builder()
|
||||
.sslSocketFactory(sslContext.socketFactory, trustManager)
|
||||
.build()
|
||||
}
|
||||
|
||||
override suspend fun createSession(
|
||||
e164: String,
|
||||
fcmToken: String?,
|
||||
mcc: String?,
|
||||
mnc: String?
|
||||
): RegistrationNetworkResult<SessionMetadata, CreateSessionError> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
pushServiceSocket.createVerificationSessionV2(e164, fcmToken, mcc, mnc).use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val session = json.decodeFromString<SessionMetadata>(response.body.string())
|
||||
RegistrationNetworkResult.Success(session)
|
||||
}
|
||||
422 -> {
|
||||
RegistrationNetworkResult.Failure(CreateSessionError.InvalidRequest(response.body.string()))
|
||||
}
|
||||
429 -> {
|
||||
RegistrationNetworkResult.Failure(CreateSessionError.RateLimited(response.retryAfter()))
|
||||
}
|
||||
else -> {
|
||||
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
RegistrationNetworkResult.NetworkError(e)
|
||||
} catch (e: Exception) {
|
||||
RegistrationNetworkResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getSession(sessionId: String): RegistrationNetworkResult<SessionMetadata, GetSessionStatusError> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
pushServiceSocket.getSessionStatusV2(sessionId).use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val session = json.decodeFromString<SessionMetadata>(response.body.string())
|
||||
RegistrationNetworkResult.Success(session)
|
||||
}
|
||||
400 -> {
|
||||
RegistrationNetworkResult.Failure(GetSessionStatusError.InvalidRequest(response.body.string()))
|
||||
}
|
||||
404 -> {
|
||||
RegistrationNetworkResult.Failure(GetSessionStatusError.SessionNotFound(response.body.string()))
|
||||
}
|
||||
422 -> {
|
||||
RegistrationNetworkResult.Failure(GetSessionStatusError.InvalidSessionId(response.body.string()))
|
||||
}
|
||||
else -> {
|
||||
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
RegistrationNetworkResult.NetworkError(e)
|
||||
} catch (e: Exception) {
|
||||
RegistrationNetworkResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateSession(
|
||||
sessionId: String?,
|
||||
pushChallengeToken: String?,
|
||||
captchaToken: String?
|
||||
): RegistrationNetworkResult<SessionMetadata, UpdateSessionError> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
pushServiceSocket.patchVerificationSessionV2(
|
||||
sessionId,
|
||||
null, // pushToken
|
||||
null, // mcc
|
||||
null, // mnc
|
||||
captchaToken,
|
||||
pushChallengeToken
|
||||
).use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val session = json.decodeFromString<SessionMetadata>(response.body.string())
|
||||
RegistrationNetworkResult.Success(session)
|
||||
}
|
||||
400 -> {
|
||||
RegistrationNetworkResult.Failure(UpdateSessionError.InvalidRequest(response.body.string()))
|
||||
}
|
||||
409 -> {
|
||||
RegistrationNetworkResult.Failure(UpdateSessionError.RejectedUpdate(response.body.string()))
|
||||
}
|
||||
429 -> {
|
||||
val session = json.decodeFromString<SessionMetadata>(response.body.string())
|
||||
RegistrationNetworkResult.Failure(UpdateSessionError.RateLimited(response.retryAfter(), session))
|
||||
}
|
||||
else -> {
|
||||
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
RegistrationNetworkResult.NetworkError(e)
|
||||
} catch (e: Exception) {
|
||||
RegistrationNetworkResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun requestVerificationCode(
|
||||
sessionId: String,
|
||||
locale: Locale?,
|
||||
androidSmsRetrieverSupported: Boolean,
|
||||
transport: VerificationCodeTransport
|
||||
): RegistrationNetworkResult<SessionMetadata, RequestVerificationCodeError> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val socketTransport = when (transport) {
|
||||
VerificationCodeTransport.SMS -> PushServiceSocket.VerificationCodeTransport.SMS
|
||||
VerificationCodeTransport.VOICE -> PushServiceSocket.VerificationCodeTransport.VOICE
|
||||
}
|
||||
|
||||
pushServiceSocket.requestVerificationCodeV2(
|
||||
sessionId,
|
||||
locale,
|
||||
androidSmsRetrieverSupported,
|
||||
socketTransport
|
||||
).use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val session = json.decodeFromString<SessionMetadata>(response.body.string())
|
||||
RegistrationNetworkResult.Success(session)
|
||||
}
|
||||
400 -> {
|
||||
RegistrationNetworkResult.Failure(RequestVerificationCodeError.InvalidSessionId(response.body.string()))
|
||||
}
|
||||
404 -> {
|
||||
RegistrationNetworkResult.Failure(RequestVerificationCodeError.SessionNotFound(response.body.string()))
|
||||
}
|
||||
409 -> {
|
||||
val session = json.decodeFromString<SessionMetadata>(response.body.string())
|
||||
RegistrationNetworkResult.Failure(RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified(session))
|
||||
}
|
||||
418 -> {
|
||||
val session = json.decodeFromString<SessionMetadata>(response.body.string())
|
||||
RegistrationNetworkResult.Failure(RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport(session))
|
||||
}
|
||||
429 -> {
|
||||
val session = json.decodeFromString<SessionMetadata>(response.body.string())
|
||||
RegistrationNetworkResult.Failure(RequestVerificationCodeError.RateLimited(response.retryAfter(), session))
|
||||
}
|
||||
440 -> {
|
||||
val errorBody = json.decodeFromString<ThirdPartyServiceErrorResponse>(response.body.string())
|
||||
RegistrationNetworkResult.Failure(RequestVerificationCodeError.ThirdPartyServiceError(errorBody))
|
||||
}
|
||||
else -> {
|
||||
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
RegistrationNetworkResult.NetworkError(e)
|
||||
} catch (e: Exception) {
|
||||
RegistrationNetworkResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun submitVerificationCode(
|
||||
sessionId: String,
|
||||
verificationCode: String
|
||||
): RegistrationNetworkResult<SessionMetadata, SubmitVerificationCodeError> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
pushServiceSocket.submitVerificationCodeV2(sessionId, verificationCode).use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val session = json.decodeFromString<SessionMetadata>(response.body.string())
|
||||
RegistrationNetworkResult.Success(session)
|
||||
}
|
||||
400 -> {
|
||||
RegistrationNetworkResult.Failure(SubmitVerificationCodeError.InvalidSessionIdOrVerificationCode(response.body.string()))
|
||||
}
|
||||
404 -> {
|
||||
RegistrationNetworkResult.Failure(SubmitVerificationCodeError.SessionNotFound(response.body.string()))
|
||||
}
|
||||
409 -> {
|
||||
val session = json.decodeFromString<SessionMetadata>(response.body.string())
|
||||
RegistrationNetworkResult.Failure(SubmitVerificationCodeError.SessionAlreadyVerifiedOrNoCodeRequested(session))
|
||||
}
|
||||
429 -> {
|
||||
val session = json.decodeFromString<SessionMetadata>(response.body.string())
|
||||
RegistrationNetworkResult.Failure(SubmitVerificationCodeError.RateLimited(response.retryAfter(), session))
|
||||
}
|
||||
else -> {
|
||||
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
RegistrationNetworkResult.NetworkError(e)
|
||||
} catch (e: Exception) {
|
||||
RegistrationNetworkResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun registerAccount(
|
||||
e164: String,
|
||||
password: String,
|
||||
sessionId: String?,
|
||||
recoveryPassword: String?,
|
||||
attributes: AccountAttributes,
|
||||
aciPreKeys: PreKeyCollection,
|
||||
pniPreKeys: PreKeyCollection,
|
||||
fcmToken: String?,
|
||||
skipDeviceTransfer: Boolean
|
||||
): RegistrationNetworkResult<RegisterAccountResponse, RegisterAccountError> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val serviceAttributes = attributes.toServiceAccountAttributes()
|
||||
val serviceAciPreKeys = aciPreKeys.toServicePreKeyCollection()
|
||||
val servicePniPreKeys = pniPreKeys.toServicePreKeyCollection()
|
||||
|
||||
pushServiceSocket.submitRegistrationRequestV2(
|
||||
e164,
|
||||
password,
|
||||
sessionId,
|
||||
recoveryPassword,
|
||||
serviceAttributes,
|
||||
serviceAciPreKeys,
|
||||
servicePniPreKeys,
|
||||
fcmToken,
|
||||
skipDeviceTransfer
|
||||
).use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val result = json.decodeFromString<RegisterAccountResponse>(response.body.string())
|
||||
RegistrationNetworkResult.Success(result)
|
||||
}
|
||||
401 -> {
|
||||
RegistrationNetworkResult.Failure(RegisterAccountError.SessionNotFoundOrNotVerified(response.body.string()))
|
||||
}
|
||||
403 -> {
|
||||
RegistrationNetworkResult.Failure(RegisterAccountError.RegistrationRecoveryPasswordIncorrect(response.body.string()))
|
||||
}
|
||||
409 -> {
|
||||
RegistrationNetworkResult.Failure(RegisterAccountError.DeviceTransferPossible)
|
||||
}
|
||||
422 -> {
|
||||
RegistrationNetworkResult.Failure(RegisterAccountError.InvalidRequest(response.body.string()))
|
||||
}
|
||||
423 -> {
|
||||
val lockResponse = json.decodeFromString<RegistrationLockResponse>(response.body.string())
|
||||
RegistrationNetworkResult.Failure(RegisterAccountError.RegistrationLock(lockResponse))
|
||||
}
|
||||
429 -> {
|
||||
RegistrationNetworkResult.Failure(RegisterAccountError.RateLimited(response.retryAfter()))
|
||||
}
|
||||
else -> {
|
||||
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
RegistrationNetworkResult.NetworkError(e)
|
||||
} catch (e: Exception) {
|
||||
RegistrationNetworkResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getFcmToken(): String? {
|
||||
return try {
|
||||
FcmUtil.getToken(context)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get FCM token", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun awaitPushChallengeToken(): String? {
|
||||
return try {
|
||||
PushChallengeReceiver.awaitChallenge()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to await push challenge token", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCaptchaUrl(): String {
|
||||
return "https://signalcaptchas.org/staging/registration/generate.html"
|
||||
}
|
||||
|
||||
override suspend fun restoreMasterKeyFromSvr(
|
||||
svr2Credentials: NetworkController.SvrCredentials,
|
||||
pin: String
|
||||
): RegistrationNetworkResult<NetworkController.MasterKeyResponse, NetworkController.RestoreMasterKeyError> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val authCredentials = AuthCredentials.create(svr2Credentials.username, svr2Credentials.password)
|
||||
|
||||
// Create a stub websocket that will never be used for pre-registration restore
|
||||
val stubWebSocketFactory = WebSocketFactory { throw UnsupportedOperationException("WebSocket not available during pre-registration") }
|
||||
val stubWebSocket = SignalWebSocket.AuthenticatedWebSocket(
|
||||
stubWebSocketFactory,
|
||||
{ false },
|
||||
object : SleepTimer {
|
||||
override fun sleep(millis: Long) = Thread.sleep(millis)
|
||||
},
|
||||
0
|
||||
)
|
||||
|
||||
val svr2 = SecureValueRecoveryV2(serviceConfiguration, svr2MrEnclave, stubWebSocket)
|
||||
|
||||
when (val response = svr2.restoreDataPreRegistration(authCredentials, null, pin)) {
|
||||
is RestoreResponse.Success -> {
|
||||
Log.i(TAG, "[restoreMasterKeyFromSvr] Successfully restored master key from SVR2")
|
||||
RegistrationNetworkResult.Success(NetworkController.MasterKeyResponse(response.masterKey))
|
||||
}
|
||||
is RestoreResponse.PinMismatch -> {
|
||||
Log.w(TAG, "[restoreMasterKeyFromSvr] PIN mismatch. Tries remaining: ${response.triesRemaining}")
|
||||
RegistrationNetworkResult.Failure(NetworkController.RestoreMasterKeyError.WrongPin(response.triesRemaining))
|
||||
}
|
||||
is RestoreResponse.Missing -> {
|
||||
Log.w(TAG, "[restoreMasterKeyFromSvr] No SVR data found for user")
|
||||
RegistrationNetworkResult.Failure(NetworkController.RestoreMasterKeyError.NoDataFound)
|
||||
}
|
||||
is RestoreResponse.NetworkError -> {
|
||||
Log.w(TAG, "[restoreMasterKeyFromSvr] Network error", response.exception)
|
||||
RegistrationNetworkResult.NetworkError(response.exception)
|
||||
}
|
||||
is RestoreResponse.ApplicationError -> {
|
||||
Log.w(TAG, "[restoreMasterKeyFromSvr] Application error", response.exception)
|
||||
RegistrationNetworkResult.ApplicationError(response.exception)
|
||||
}
|
||||
is RestoreResponse.EnclaveNotFound -> {
|
||||
Log.w(TAG, "[restoreMasterKeyFromSvr] Enclave not found")
|
||||
RegistrationNetworkResult.ApplicationError(IllegalStateException("SVR2 enclave not found"))
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "[restoreMasterKeyFromSvr] IOException", e)
|
||||
RegistrationNetworkResult.NetworkError(e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "[restoreMasterKeyFromSvr] Exception", e)
|
||||
RegistrationNetworkResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setPinAndMasterKeyOnSvr(
|
||||
pin: String,
|
||||
masterKey: MasterKey
|
||||
): RegistrationNetworkResult<Unit, NetworkController.BackupMasterKeyError> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val aci = RegistrationPreferences.aci
|
||||
val pni = RegistrationPreferences.pni
|
||||
val e164 = RegistrationPreferences.e164
|
||||
val password = RegistrationPreferences.servicePassword
|
||||
|
||||
if (aci == null || e164 == null || password == null) {
|
||||
Log.w(TAG, "[backupMasterKeyToSvr] Credentials not available, cannot authenticate")
|
||||
return@withContext RegistrationNetworkResult.Failure(NetworkController.BackupMasterKeyError.NotRegistered)
|
||||
}
|
||||
|
||||
val network = Network(Network.Environment.STAGING, "Signal-Android-Registration-Sample", emptyMap(), Network.BuildVariant.PRODUCTION)
|
||||
val credentialsProvider = StaticCredentialsProvider(aci, pni, e164, 1, password)
|
||||
val healthMonitor = object : HealthMonitor {
|
||||
override fun onKeepAliveResponse(sentTimestamp: Long, isIdentifiedWebSocket: Boolean) {}
|
||||
override fun onMessageError(status: Int, isIdentifiedWebSocket: Boolean) {}
|
||||
}
|
||||
|
||||
val libSignalConnection = LibSignalChatConnection(
|
||||
name = "SVR-Backup",
|
||||
network = network,
|
||||
credentialsProvider = credentialsProvider,
|
||||
receiveStories = false,
|
||||
healthMonitor = healthMonitor
|
||||
)
|
||||
|
||||
val authWebSocket = SignalWebSocket.AuthenticatedWebSocket(
|
||||
connectionFactory = { libSignalConnection },
|
||||
canConnect = { true },
|
||||
sleepTimer = { millis -> Thread.sleep(millis) },
|
||||
disconnectTimeoutMs = 60.seconds.inWholeMilliseconds
|
||||
)
|
||||
|
||||
authWebSocket.connect()
|
||||
|
||||
val svr2 = SecureValueRecoveryV2(serviceConfiguration, svr2MrEnclave, authWebSocket)
|
||||
val session = svr2.setPin(pin, masterKey)
|
||||
val response = session.execute()
|
||||
|
||||
authWebSocket.disconnect()
|
||||
|
||||
when (response) {
|
||||
is BackupResponse.Success -> {
|
||||
Log.i(TAG, "[backupMasterKeyToSvr] Successfully backed up master key to SVR2")
|
||||
RegistrationNetworkResult.Success(Unit)
|
||||
}
|
||||
is BackupResponse.ApplicationError -> {
|
||||
Log.w(TAG, "[backupMasterKeyToSvr] Application error", response.exception)
|
||||
RegistrationNetworkResult.ApplicationError(response.exception)
|
||||
}
|
||||
is BackupResponse.NetworkError -> {
|
||||
Log.w(TAG, "[backupMasterKeyToSvr] Network error", response.exception)
|
||||
RegistrationNetworkResult.NetworkError(response.exception)
|
||||
}
|
||||
is BackupResponse.EnclaveNotFound -> {
|
||||
Log.w(TAG, "[backupMasterKeyToSvr] Enclave not found")
|
||||
RegistrationNetworkResult.Failure(NetworkController.BackupMasterKeyError.EnclaveNotFound)
|
||||
}
|
||||
is BackupResponse.ExposeFailure -> {
|
||||
Log.w(TAG, "[backupMasterKeyToSvr] Expose failure -- per spec, treat as success.")
|
||||
RegistrationNetworkResult.Success(Unit)
|
||||
}
|
||||
is BackupResponse.ServerRejected -> {
|
||||
Log.w(TAG, "[backupMasterKeyToSvr] Server rejected")
|
||||
RegistrationNetworkResult.NetworkError(IOException("Server rejected backup request"))
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "[backupMasterKeyToSvr] IOException", e)
|
||||
RegistrationNetworkResult.NetworkError(e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "[backupMasterKeyToSvr] Exception", e)
|
||||
RegistrationNetworkResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun enableRegistrationLock(): RegistrationNetworkResult<Unit, NetworkController.SetRegistrationLockError> = withContext(Dispatchers.IO) {
|
||||
val aci = RegistrationPreferences.aci
|
||||
val password = RegistrationPreferences.servicePassword
|
||||
val masterKey = RegistrationPreferences.masterKey
|
||||
|
||||
if (aci == null || password == null) {
|
||||
Log.w(TAG, "[enableRegistrationLock] Credentials not available")
|
||||
return@withContext RegistrationNetworkResult.Failure(NetworkController.SetRegistrationLockError.NotRegistered)
|
||||
}
|
||||
|
||||
if (masterKey == null) {
|
||||
Log.w(TAG, "[enableRegistrationLock] Master key not available")
|
||||
return@withContext RegistrationNetworkResult.Failure(NetworkController.SetRegistrationLockError.NoPinSet)
|
||||
}
|
||||
|
||||
val registrationLockToken = masterKey.deriveRegistrationLock()
|
||||
|
||||
try {
|
||||
val credentials = okhttp3.Credentials.basic(aci.toString(), password)
|
||||
val baseUrl = serviceConfiguration.signalServiceUrls[0].url
|
||||
val requestBody = """{"registrationLock":"$registrationLockToken"}"""
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url("$baseUrl/v1/accounts/registration_lock")
|
||||
.put(requestBody)
|
||||
.header("Authorization", credentials)
|
||||
.build()
|
||||
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
when (response.code) {
|
||||
200, 204 -> {
|
||||
Log.i(TAG, "[enableRegistrationLock] Successfully enabled registration lock")
|
||||
RegistrationNetworkResult.Success(Unit)
|
||||
}
|
||||
401 -> {
|
||||
RegistrationNetworkResult.Failure(NetworkController.SetRegistrationLockError.Unauthorized)
|
||||
}
|
||||
422 -> {
|
||||
RegistrationNetworkResult.Failure(NetworkController.SetRegistrationLockError.InvalidRequest(response.body?.string() ?: ""))
|
||||
}
|
||||
else -> {
|
||||
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "[enableRegistrationLock] IOException", e)
|
||||
RegistrationNetworkResult.NetworkError(e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "[enableRegistrationLock] Exception", e)
|
||||
RegistrationNetworkResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun disableRegistrationLock(): RegistrationNetworkResult<Unit, NetworkController.SetRegistrationLockError> = withContext(Dispatchers.IO) {
|
||||
val aci = RegistrationPreferences.aci
|
||||
val password = RegistrationPreferences.servicePassword
|
||||
|
||||
if (aci == null || password == null) {
|
||||
Log.w(TAG, "[disableRegistrationLock] Credentials not available")
|
||||
return@withContext RegistrationNetworkResult.Failure(NetworkController.SetRegistrationLockError.NotRegistered)
|
||||
}
|
||||
|
||||
try {
|
||||
val credentials = okhttp3.Credentials.basic(aci.toString(), password)
|
||||
val baseUrl = serviceConfiguration.signalServiceUrls[0].url
|
||||
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url("$baseUrl/v1/accounts/registration_lock")
|
||||
.delete()
|
||||
.header("Authorization", credentials)
|
||||
.build()
|
||||
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
when (response.code) {
|
||||
200, 204 -> {
|
||||
Log.i(TAG, "[disableRegistrationLock] Successfully disabled registration lock")
|
||||
RegistrationNetworkResult.Success(Unit)
|
||||
}
|
||||
401 -> {
|
||||
RegistrationNetworkResult.Failure(NetworkController.SetRegistrationLockError.Unauthorized)
|
||||
}
|
||||
else -> {
|
||||
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "[disableRegistrationLock] IOException", e)
|
||||
RegistrationNetworkResult.NetworkError(e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "[disableRegistrationLock] Exception", e)
|
||||
RegistrationNetworkResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setAccountAttributes(
|
||||
attributes: AccountAttributes
|
||||
): RegistrationNetworkResult<Unit, NetworkController.SetAccountAttributesError> = withContext(Dispatchers.IO) {
|
||||
val aci = RegistrationPreferences.aci
|
||||
val password = RegistrationPreferences.servicePassword
|
||||
|
||||
if (aci == null || password == null) {
|
||||
Log.w(TAG, "[setAccountAttributes] Credentials not available")
|
||||
return@withContext RegistrationNetworkResult.Failure(NetworkController.SetAccountAttributesError.Unauthorized)
|
||||
}
|
||||
|
||||
try {
|
||||
val credentials = okhttp3.Credentials.basic(aci.toString(), password)
|
||||
val baseUrl = serviceConfiguration.signalServiceUrls[0].url
|
||||
val requestBody = json.encodeToString(AccountAttributes.serializer(), attributes)
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url("$baseUrl/v1/accounts/attributes")
|
||||
.put(requestBody)
|
||||
.header("Authorization", credentials)
|
||||
.build()
|
||||
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
when (response.code) {
|
||||
200, 204 -> {
|
||||
Log.i(TAG, "[setAccountAttributes] Successfully updated account attributes")
|
||||
RegistrationNetworkResult.Success(Unit)
|
||||
}
|
||||
401 -> {
|
||||
RegistrationNetworkResult.Failure(NetworkController.SetAccountAttributesError.Unauthorized)
|
||||
}
|
||||
422 -> {
|
||||
RegistrationNetworkResult.Failure(NetworkController.SetAccountAttributesError.InvalidRequest(response.body?.string() ?: ""))
|
||||
}
|
||||
else -> {
|
||||
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body?.string()}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "[setAccountAttributes] IOException", e)
|
||||
RegistrationNetworkResult.NetworkError(e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "[setAccountAttributes] Exception", e)
|
||||
RegistrationNetworkResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getSvrCredentials(): RegistrationNetworkResult<NetworkController.SvrCredentials, NetworkController.GetSvrCredentialsError> = withContext(Dispatchers.IO) {
|
||||
val aci = RegistrationPreferences.aci
|
||||
val password = RegistrationPreferences.servicePassword
|
||||
|
||||
if (aci == null || password == null) {
|
||||
Log.w(TAG, "[getSvrCredentials] Credentials not available")
|
||||
return@withContext RegistrationNetworkResult.Failure(NetworkController.GetSvrCredentialsError.NoServiceCredentialsAvailable)
|
||||
}
|
||||
|
||||
try {
|
||||
val credentials = okhttp3.Credentials.basic(aci.toString(), password)
|
||||
val baseUrl = serviceConfiguration.signalServiceUrls[0].url
|
||||
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url("$baseUrl/v2/svr/auth")
|
||||
.get()
|
||||
.header("Authorization", credentials)
|
||||
.build()
|
||||
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val svrCredentials = json.decodeFromString<NetworkController.SvrCredentials>(response.body.string())
|
||||
RegistrationNetworkResult.Success(svrCredentials)
|
||||
}
|
||||
401 -> {
|
||||
RegistrationNetworkResult.Failure(NetworkController.GetSvrCredentialsError.Unauthorized)
|
||||
}
|
||||
else -> {
|
||||
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "[getSvrCredentials] IOException", e)
|
||||
RegistrationNetworkResult.NetworkError(e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "[getSvrCredentials] Exception", e)
|
||||
RegistrationNetworkResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AccountAttributes.toServiceAccountAttributes(): ServiceAccountAttributes {
|
||||
return ServiceAccountAttributes(
|
||||
signalingKey,
|
||||
registrationId,
|
||||
fetchesMessages,
|
||||
registrationLock,
|
||||
unidentifiedAccessKey,
|
||||
unrestrictedUnidentifiedAccess,
|
||||
capabilities?.toServiceCapabilities(),
|
||||
discoverableByPhoneNumber,
|
||||
name,
|
||||
pniRegistrationId,
|
||||
recoveryPassword
|
||||
)
|
||||
}
|
||||
|
||||
private fun AccountAttributes.Capabilities.toServiceCapabilities(): ServiceAccountAttributes.Capabilities {
|
||||
return ServiceAccountAttributes.Capabilities(
|
||||
storage,
|
||||
versionedExpirationTimer,
|
||||
attachmentBackfill,
|
||||
spqr
|
||||
)
|
||||
}
|
||||
|
||||
private fun PreKeyCollection.toServicePreKeyCollection(): ServicePreKeyCollection {
|
||||
return ServicePreKeyCollection(
|
||||
identityKey = identityKey,
|
||||
signedPreKey = signedPreKey,
|
||||
lastResortKyberPreKey = lastResortKyberPreKey
|
||||
)
|
||||
}
|
||||
|
||||
private fun Response.retryAfter(): Duration {
|
||||
return this.header("Retry-After")?.toLongOrNull()?.seconds ?: 0.seconds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.dependencies
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.models.AccountEntropyPool
|
||||
import org.signal.core.models.MasterKey
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.protocol.kem.KEMKeyPair
|
||||
import org.signal.libsignal.protocol.kem.KEMKeyType
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.signal.registration.KeyMaterial
|
||||
import org.signal.registration.NewRegistrationData
|
||||
import org.signal.registration.PreExistingRegistrationData
|
||||
import org.signal.registration.StorageController
|
||||
import org.signal.registration.sample.storage.RegistrationDatabase
|
||||
import org.signal.registration.sample.storage.RegistrationPreferences
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Implementation of [StorageController] that persists registration data using
|
||||
* SharedPreferences for simple key-value data and SQLite for prekeys.
|
||||
*/
|
||||
class RealStorageController(context: Context) : StorageController {
|
||||
|
||||
private val db = RegistrationDatabase(context)
|
||||
|
||||
override suspend fun generateAndStoreKeyMaterial(): KeyMaterial = withContext(Dispatchers.IO) {
|
||||
val accountEntropyPool = AccountEntropyPool.generate()
|
||||
val aciIdentityKeyPair = IdentityKeyPair.generate()
|
||||
val pniIdentityKeyPair = IdentityKeyPair.generate()
|
||||
|
||||
val aciSignedPreKeyId = generatePreKeyId()
|
||||
val pniSignedPreKeyId = generatePreKeyId()
|
||||
val aciKyberPreKeyId = generatePreKeyId()
|
||||
val pniKyberPreKeyId = generatePreKeyId()
|
||||
|
||||
val timestamp = System.currentTimeMillis()
|
||||
|
||||
val aciSignedPreKey = generateSignedPreKey(aciSignedPreKeyId, timestamp, aciIdentityKeyPair)
|
||||
val pniSignedPreKey = generateSignedPreKey(pniSignedPreKeyId, timestamp, pniIdentityKeyPair)
|
||||
val aciLastResortKyberPreKey = generateKyberPreKey(aciKyberPreKeyId, timestamp, aciIdentityKeyPair)
|
||||
val pniLastResortKyberPreKey = generateKyberPreKey(pniKyberPreKeyId, timestamp, pniIdentityKeyPair)
|
||||
|
||||
val aciRegistrationId = generateRegistrationId()
|
||||
val pniRegistrationId = generateRegistrationId()
|
||||
val profileKey = generateProfileKey()
|
||||
val unidentifiedAccessKey = deriveUnidentifiedAccessKey(profileKey)
|
||||
val password = generatePassword()
|
||||
|
||||
val keyMaterial = KeyMaterial(
|
||||
aciIdentityKeyPair = aciIdentityKeyPair,
|
||||
aciSignedPreKey = aciSignedPreKey,
|
||||
aciLastResortKyberPreKey = aciLastResortKyberPreKey,
|
||||
pniIdentityKeyPair = pniIdentityKeyPair,
|
||||
pniSignedPreKey = pniSignedPreKey,
|
||||
pniLastResortKyberPreKey = pniLastResortKyberPreKey,
|
||||
aciRegistrationId = aciRegistrationId,
|
||||
pniRegistrationId = pniRegistrationId,
|
||||
unidentifiedAccessKey = unidentifiedAccessKey,
|
||||
servicePassword = password,
|
||||
accountEntropyPool = accountEntropyPool
|
||||
)
|
||||
|
||||
storeKeyMaterial(keyMaterial, profileKey)
|
||||
|
||||
keyMaterial
|
||||
}
|
||||
|
||||
override suspend fun saveNewRegistrationData(newRegistrationData: NewRegistrationData) = withContext(Dispatchers.IO) {
|
||||
RegistrationPreferences.saveRegistrationData(newRegistrationData)
|
||||
}
|
||||
|
||||
override suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? = withContext(Dispatchers.IO) {
|
||||
RegistrationPreferences.getPreExistingRegistrationData()
|
||||
}
|
||||
|
||||
override suspend fun clearAllData() = withContext(Dispatchers.IO) {
|
||||
RegistrationPreferences.clearAll()
|
||||
db.clearAllPreKeys()
|
||||
}
|
||||
|
||||
override suspend fun saveValidatedPinAndTemporaryMasterKey(pin: String, isAlphanumeric: Boolean, masterKey: MasterKey, registrationLockEnabled: Boolean) = withContext(Dispatchers.IO) {
|
||||
RegistrationPreferences.pin = pin
|
||||
RegistrationPreferences.pinAlphanumeric = isAlphanumeric
|
||||
RegistrationPreferences.temporaryMasterKey = masterKey
|
||||
RegistrationPreferences.registrationLockEnabled = registrationLockEnabled
|
||||
}
|
||||
|
||||
override suspend fun saveNewlyCreatedPin(pin: String, isAlphanumeric: Boolean) {
|
||||
RegistrationPreferences.pin = pin
|
||||
RegistrationPreferences.pinAlphanumeric = isAlphanumeric
|
||||
}
|
||||
|
||||
private fun storeKeyMaterial(keyMaterial: KeyMaterial, profileKey: ProfileKey) {
|
||||
// Clear existing data
|
||||
RegistrationPreferences.clearKeyMaterial()
|
||||
db.clearAllPreKeys()
|
||||
|
||||
// Store in SharedPreferences
|
||||
RegistrationPreferences.aciIdentityKeyPair = keyMaterial.aciIdentityKeyPair
|
||||
RegistrationPreferences.pniIdentityKeyPair = keyMaterial.pniIdentityKeyPair
|
||||
RegistrationPreferences.aciRegistrationId = keyMaterial.aciRegistrationId
|
||||
RegistrationPreferences.pniRegistrationId = keyMaterial.pniRegistrationId
|
||||
RegistrationPreferences.profileKey = profileKey
|
||||
|
||||
// Store prekeys in database
|
||||
db.signedPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_ACI, keyMaterial.aciSignedPreKey)
|
||||
db.signedPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_PNI, keyMaterial.pniSignedPreKey)
|
||||
db.kyberPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_ACI, keyMaterial.aciLastResortKyberPreKey)
|
||||
db.kyberPreKeys.insert(RegistrationDatabase.ACCOUNT_TYPE_PNI, keyMaterial.pniLastResortKyberPreKey)
|
||||
}
|
||||
|
||||
private fun generateSignedPreKey(id: Int, timestamp: Long, identityKeyPair: IdentityKeyPair): SignedPreKeyRecord {
|
||||
val keyPair = ECKeyPair.generate()
|
||||
val signature = identityKeyPair.privateKey.calculateSignature(keyPair.publicKey.serialize())
|
||||
return SignedPreKeyRecord(id, timestamp, keyPair, signature)
|
||||
}
|
||||
|
||||
private fun generateKyberPreKey(id: Int, timestamp: Long, identityKeyPair: IdentityKeyPair): KyberPreKeyRecord {
|
||||
val kemKeyPair = KEMKeyPair.generate(KEMKeyType.KYBER_1024)
|
||||
val signature = identityKeyPair.privateKey.calculateSignature(kemKeyPair.publicKey.serialize())
|
||||
return KyberPreKeyRecord(id, timestamp, kemKeyPair, signature)
|
||||
}
|
||||
|
||||
private fun generatePreKeyId(): Int {
|
||||
return SecureRandom().nextInt(Int.MAX_VALUE - 1) + 1
|
||||
}
|
||||
|
||||
private fun generateRegistrationId(): Int {
|
||||
return SecureRandom().nextInt(16380) + 1
|
||||
}
|
||||
|
||||
private fun generateProfileKey(): ProfileKey {
|
||||
val keyBytes = ByteArray(32)
|
||||
SecureRandom().nextBytes(keyBytes)
|
||||
return ProfileKey(keyBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a password for basic auth during registration.
|
||||
* 18 random bytes, base64 encoded with padding.
|
||||
*/
|
||||
private fun generatePassword(): String {
|
||||
val passwordBytes = ByteArray(18)
|
||||
SecureRandom().nextBytes(passwordBytes)
|
||||
return Base64.encodeWithPadding(passwordBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the unidentified access key from a profile key.
|
||||
* This mirrors the logic in UnidentifiedAccess.deriveAccessKeyFrom().
|
||||
*/
|
||||
private fun deriveUnidentifiedAccessKey(profileKey: ProfileKey): ByteArray {
|
||||
val nonce = ByteArray(12)
|
||||
val input = ByteArray(16)
|
||||
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(profileKey.serialize(), "AES"), GCMParameterSpec(128, nonce))
|
||||
|
||||
val ciphertext = cipher.doFinal(input)
|
||||
return ciphertext.copyOf(16)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.fcm
|
||||
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
/**
|
||||
* Firebase Cloud Messaging service for receiving push notifications.
|
||||
* During registration, this is used to receive push challenge tokens from the server.
|
||||
*/
|
||||
class FcmReceiveService : FirebaseMessagingService() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(FcmReceiveService::class)
|
||||
}
|
||||
|
||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||
Log.d(TAG, "onMessageReceived: ${remoteMessage.messageId}")
|
||||
|
||||
val challenge = remoteMessage.data["challenge"]
|
||||
if (challenge != null) {
|
||||
Log.d(TAG, "Received push challenge")
|
||||
PushChallengeReceiver.onChallengeReceived(challenge)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
Log.d(TAG, "onNewToken")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.fcm
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
/**
|
||||
* Utility functions for Firebase Cloud Messaging.
|
||||
*/
|
||||
object FcmUtil {
|
||||
|
||||
private val TAG = Log.tag(FcmUtil::class)
|
||||
|
||||
/**
|
||||
* Retrieves the FCM registration token if available.
|
||||
* Returns null if FCM is not available on this device.
|
||||
*
|
||||
* @param context Application context needed to initialize Firebase
|
||||
*/
|
||||
suspend fun getToken(context: Context): String? {
|
||||
return try {
|
||||
FirebaseApp.initializeApp(context)
|
||||
|
||||
val token = FirebaseMessaging.getInstance().token.await()
|
||||
Log.d(TAG, "FCM token retrieved successfully")
|
||||
token
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get FCM token", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if Google Play Services is available on this device.
|
||||
*/
|
||||
fun isPlayServicesAvailable(context: Context): Boolean {
|
||||
val availability = GoogleApiAvailability.getInstance()
|
||||
val resultCode = availability.isGooglePlayServicesAvailable(context)
|
||||
return resultCode == ConnectionResult.SUCCESS
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.fcm
|
||||
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
/**
|
||||
* Singleton that receives push challenge tokens from FCM and makes them
|
||||
* available to the registration flow.
|
||||
*/
|
||||
object PushChallengeReceiver {
|
||||
|
||||
private val TAG = Log.tag(PushChallengeReceiver::class)
|
||||
|
||||
private val challengeFlow = MutableSharedFlow<String>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
|
||||
/**
|
||||
* Called by FcmReceiveService when a push challenge is received.
|
||||
*/
|
||||
fun onChallengeReceived(challenge: String) {
|
||||
Log.d(TAG, "Push challenge received")
|
||||
challengeFlow.tryEmit(challenge)
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspends until a push challenge token is received.
|
||||
* The caller should wrap this in withTimeoutOrNull to handle timeout.
|
||||
*/
|
||||
suspend fun awaitChallenge(): String {
|
||||
Log.d(TAG, "Waiting for push challenge...")
|
||||
return challengeFlow.first()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Previews
|
||||
|
||||
@Composable
|
||||
fun RegistrationCompleteScreen(
|
||||
onStartOver: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Registration Complete!",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onStartOver,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 48.dp)
|
||||
) {
|
||||
Text("Start Over")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun RegistrationCompleteScreenPreview() {
|
||||
Previews.Preview {
|
||||
RegistrationCompleteScreen(onStartOver = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.screens.main
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Previews
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
state: MainScreenState,
|
||||
onEvent: (MainScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showClearDataDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showClearDataDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showClearDataDialog = false },
|
||||
title = { Text("Clear All Data?") },
|
||||
text = { Text("This will delete all registration data including your account information, keys, and PIN. This cannot be undone.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showClearDataDialog = false
|
||||
onEvent(MainScreenEvents.ClearAllData)
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Clear")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showClearDataDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
if (state.existingRegistrationState == null) {
|
||||
Text(
|
||||
text = "Registration Sample App",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Test the registration flow",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
|
||||
if (state.existingRegistrationState != null) {
|
||||
RegistrationInfo(state.existingRegistrationState)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = { onEvent(MainScreenEvents.LaunchRegistration) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Re-register")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { onEvent(MainScreenEvents.OpenPinSettings) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("PIN & Registration Lock Settings")
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = { showClearDataDialog = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Clear All Data")
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { onEvent(MainScreenEvents.LaunchRegistration) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Start Registration")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RegistrationInfo(data: MainScreenState.ExistingRegistrationState) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Registered Account",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
RegistrationField(label = "Phone Number", value = data.phoneNumber)
|
||||
RegistrationField(label = "ACI", value = data.aci)
|
||||
RegistrationField(label = "PNI", value = data.pni)
|
||||
RegistrationField(label = "AEP", value = data.aep)
|
||||
RegistrationField(label = "Temporary Master Key", value = data.temporaryMasterKey ?: "null")
|
||||
if (data.pinsOptedOut) {
|
||||
RegistrationField(label = "PINs Opted Out", value = "Yes")
|
||||
} else {
|
||||
RegistrationField(label = "PIN", value = data.pin ?: "(not set)")
|
||||
RegistrationField(label = "Registration Lock", value = if (data.registrationLockEnabled) "Enabled" else "Disabled")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RegistrationField(label: String, value: String) {
|
||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun MainScreenPreview() {
|
||||
Previews.Preview {
|
||||
MainScreen(
|
||||
state = MainScreenState(),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun MainScreenWithRegistrationPreview() {
|
||||
Previews.Preview {
|
||||
MainScreen(
|
||||
state = MainScreenState(
|
||||
existingRegistrationState = MainScreenState.ExistingRegistrationState(
|
||||
phoneNumber = "+15551234567",
|
||||
aci = "12345678-1234-1234-1234-123456789abc",
|
||||
pni = "abcdefab-abcd-abcd-abcd-abcdefabcdef",
|
||||
aep = "aep1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd",
|
||||
pin = "1234",
|
||||
registrationLockEnabled = true,
|
||||
pinsOptedOut = false,
|
||||
temporaryMasterKey = null
|
||||
)
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.screens.main
|
||||
|
||||
sealed interface MainScreenEvents {
|
||||
data object LaunchRegistration : MainScreenEvents
|
||||
data object OpenPinSettings : MainScreenEvents
|
||||
data object ClearAllData : MainScreenEvents
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.screens.main
|
||||
|
||||
data class MainScreenState(
|
||||
val existingRegistrationState: ExistingRegistrationState? = null
|
||||
) {
|
||||
data class ExistingRegistrationState(
|
||||
val phoneNumber: String,
|
||||
val aci: String,
|
||||
val pni: String,
|
||||
val aep: String,
|
||||
val pin: String?,
|
||||
val registrationLockEnabled: Boolean,
|
||||
val pinsOptedOut: Boolean,
|
||||
val temporaryMasterKey: String?
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.screens.main
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.registration.StorageController
|
||||
import org.signal.registration.sample.storage.RegistrationPreferences
|
||||
|
||||
class MainScreenViewModel(
|
||||
private val storageController: StorageController,
|
||||
private val onLaunchRegistration: () -> Unit,
|
||||
private val onOpenPinSettings: () -> Unit
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(MainScreenState())
|
||||
val state: StateFlow<MainScreenState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
loadRegistrationData()
|
||||
}
|
||||
|
||||
fun refreshData() {
|
||||
loadRegistrationData()
|
||||
}
|
||||
|
||||
fun onEvent(event: MainScreenEvents) {
|
||||
viewModelScope.launch {
|
||||
when (event) {
|
||||
MainScreenEvents.LaunchRegistration -> onLaunchRegistration()
|
||||
MainScreenEvents.OpenPinSettings -> onOpenPinSettings()
|
||||
MainScreenEvents.ClearAllData -> {
|
||||
storageController.clearAllData()
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadRegistrationData() {
|
||||
viewModelScope.launch {
|
||||
val existingData = storageController.getPreExistingRegistrationData()
|
||||
_state.value = _state.value.copy(
|
||||
existingRegistrationState = if (existingData != null) {
|
||||
MainScreenState.ExistingRegistrationState(
|
||||
phoneNumber = existingData.e164,
|
||||
aci = existingData.aci.toString(),
|
||||
pni = existingData.pni.toStringWithoutPrefix(),
|
||||
aep = existingData.aep.value,
|
||||
pin = RegistrationPreferences.pin,
|
||||
registrationLockEnabled = RegistrationPreferences.registrationLockEnabled,
|
||||
pinsOptedOut = RegistrationPreferences.pinsOptedOut,
|
||||
temporaryMasterKey = RegistrationPreferences.temporaryMasterKey?.let {
|
||||
Base64.encodeWithPadding(it.serialize())
|
||||
}
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val storageController: StorageController,
|
||||
private val onLaunchRegistration: () -> Unit,
|
||||
private val onOpenPinSettings: () -> Unit
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return MainScreenViewModel(storageController, onLaunchRegistration, onOpenPinSettings) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.screens.pinsettings
|
||||
|
||||
sealed interface PinSettingsEvents {
|
||||
data class SetPin(val pin: String) : PinSettingsEvents
|
||||
data object ToggleRegistrationLock : PinSettingsEvents
|
||||
data object TogglePinsOptOut : PinSettingsEvents
|
||||
data object Back : PinSettingsEvents
|
||||
data object DismissMessage : PinSettingsEvents
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.screens.pinsettings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PinSettingsScreen(
|
||||
state: PinSettingsState,
|
||||
onEvent: (PinSettingsEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var pinInput by remember { mutableStateOf("") }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("PIN Settings") },
|
||||
navigationIcon = {
|
||||
TextButton(onClick = { onEvent(PinSettingsEvents.Back) }) {
|
||||
Text("Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
if (state.toastMessage != null) {
|
||||
Snackbar(
|
||||
action = {
|
||||
TextButton(onClick = { onEvent(PinSettingsEvents.DismissMessage) }) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(state.toastMessage)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// PIN Setup Section
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Set Your PIN",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Your PIN protects your account and allows you to restore your data if you need to re-register.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = pinInput,
|
||||
onValueChange = { if (it.length <= 6) pinInput = it },
|
||||
label = { Text("Enter PIN (4-6 digits)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.NumberPassword,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
if (pinInput.length >= 4 && !state.pinsOptedOut) {
|
||||
onEvent(PinSettingsEvents.SetPin(pinInput))
|
||||
}
|
||||
}
|
||||
),
|
||||
enabled = !state.loading && !state.pinsOptedOut
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = { onEvent(PinSettingsEvents.SetPin(pinInput)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = pinInput.length >= 4 && !state.loading && !state.pinsOptedOut
|
||||
) {
|
||||
Text(if (state.hasPinSet) "Update PIN" else "Set PIN")
|
||||
}
|
||||
|
||||
if (state.hasPinSet && !state.pinsOptedOut) {
|
||||
Text(
|
||||
text = "PIN is currently set",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (state.pinsOptedOut) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text(
|
||||
text = "Opt back into PINs to set a PIN",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Registration Lock Section
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Registration Lock",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = "When enabled, your PIN will be required when re-registering your phone number.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = state.registrationLockEnabled,
|
||||
onCheckedChange = { onEvent(PinSettingsEvents.ToggleRegistrationLock) },
|
||||
enabled = state.hasPinSet && !state.loading && !state.pinsOptedOut
|
||||
)
|
||||
}
|
||||
|
||||
if (!state.hasPinSet && !state.pinsOptedOut) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text(
|
||||
text = "Set a PIN first to enable registration lock",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
if (state.pinsOptedOut) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text(
|
||||
text = "Opt back into PINs to enable registration lock",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// PIN Opt-Out Section
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Opt Out of PINs",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = "Disables PIN-based account recovery. Your data will not be backed up to the server.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = state.pinsOptedOut,
|
||||
onCheckedChange = { onEvent(PinSettingsEvents.TogglePinsOptOut) },
|
||||
enabled = !state.registrationLockEnabled && !state.loading
|
||||
)
|
||||
}
|
||||
|
||||
if (state.registrationLockEnabled) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text(
|
||||
text = "Disable registration lock first to opt out of PINs",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (state.loading) {
|
||||
Dialogs.IndeterminateProgressDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PinSettingsScreenPreview() {
|
||||
Previews.Preview {
|
||||
PinSettingsScreen(
|
||||
state = PinSettingsState(),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PinSettingsScreenWithPinPreview() {
|
||||
Previews.Preview {
|
||||
PinSettingsScreen(
|
||||
state = PinSettingsState(
|
||||
hasPinSet = true,
|
||||
registrationLockEnabled = true
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PinSettingsScreenLoadingPreview() {
|
||||
Previews.Preview {
|
||||
PinSettingsScreen(
|
||||
state = PinSettingsState(
|
||||
loading = true
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PinSettingsScreenOptedOutPreview() {
|
||||
Previews.Preview {
|
||||
PinSettingsScreen(
|
||||
state = PinSettingsState(
|
||||
pinsOptedOut = true
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.screens.pinsettings
|
||||
|
||||
data class PinSettingsState(
|
||||
val hasPinSet: Boolean = false,
|
||||
val registrationLockEnabled: Boolean = false,
|
||||
val pinsOptedOut: Boolean = false,
|
||||
val loading: Boolean = false,
|
||||
val toastMessage: String? = null
|
||||
)
|
||||
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.screens.pinsettings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.registration.NetworkController
|
||||
import org.signal.registration.NetworkController.RegistrationNetworkResult
|
||||
import org.signal.registration.sample.storage.RegistrationPreferences
|
||||
|
||||
/**
|
||||
* ViewModel for the PIN settings screen.
|
||||
*
|
||||
* Handles setting PIN via SVR backup and enabling/disabling registration lock.
|
||||
*/
|
||||
class PinSettingsViewModel(
|
||||
private val networkController: NetworkController,
|
||||
private val onBack: () -> Unit
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(PinSettingsViewModel::class)
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(
|
||||
PinSettingsState(
|
||||
hasPinSet = RegistrationPreferences.hasPin,
|
||||
registrationLockEnabled = RegistrationPreferences.registrationLockEnabled,
|
||||
pinsOptedOut = RegistrationPreferences.pinsOptedOut
|
||||
)
|
||||
)
|
||||
val state: StateFlow<PinSettingsState> = _state.asStateFlow()
|
||||
|
||||
fun onEvent(event: PinSettingsEvents) {
|
||||
when (event) {
|
||||
is PinSettingsEvents.SetPin -> {
|
||||
_state.value = _state.value.copy(loading = true)
|
||||
handleSetPin(event.pin)
|
||||
_state.value = _state.value.copy(loading = true)
|
||||
}
|
||||
is PinSettingsEvents.ToggleRegistrationLock -> {
|
||||
_state.value = _state.value.copy(loading = true)
|
||||
handleToggleRegistrationLock()
|
||||
_state.value = _state.value.copy(loading = false)
|
||||
}
|
||||
is PinSettingsEvents.TogglePinsOptOut -> {
|
||||
_state.value = _state.value.copy(loading = true)
|
||||
handleTogglePinsOptOut()
|
||||
}
|
||||
is PinSettingsEvents.Back -> onBack()
|
||||
is PinSettingsEvents.DismissMessage -> dismissMessage()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSetPin(pin: String) {
|
||||
if (pin.length < 4) {
|
||||
_state.value = _state.value.copy(toastMessage = "PIN must be at least 4 digits")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
// Generate or reuse existing master key
|
||||
val masterKey = RegistrationPreferences.masterKey ?: run {
|
||||
_state.value = _state.value.copy(toastMessage = "No master key found!")
|
||||
return@launch
|
||||
}
|
||||
|
||||
when (val result = networkController.setPinAndMasterKeyOnSvr(pin, masterKey)) {
|
||||
is RegistrationNetworkResult.Success -> {
|
||||
Log.i(TAG, "Successfully backed up PIN to SVR")
|
||||
RegistrationPreferences.pin = pin
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
hasPinSet = true,
|
||||
toastMessage = "PIN has been set successfully"
|
||||
)
|
||||
}
|
||||
is RegistrationNetworkResult.Failure -> {
|
||||
Log.w(TAG, "Failed to backup PIN: ${result.error}")
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
toastMessage = "Failed to set PIN: ${result.error::class.simpleName}"
|
||||
)
|
||||
}
|
||||
is RegistrationNetworkResult.NetworkError -> {
|
||||
Log.w(TAG, "Network error while setting PIN", result.exception)
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
toastMessage = "Network error. Please check your connection."
|
||||
)
|
||||
}
|
||||
is RegistrationNetworkResult.ApplicationError -> {
|
||||
Log.w(TAG, "Application error while setting PIN", result.exception)
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
toastMessage = "An error occurred: ${result.exception.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleToggleRegistrationLock() {
|
||||
val currentlyEnabled = _state.value.registrationLockEnabled
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = if (currentlyEnabled) {
|
||||
networkController.disableRegistrationLock()
|
||||
} else {
|
||||
networkController.enableRegistrationLock()
|
||||
}
|
||||
|
||||
when (result) {
|
||||
is RegistrationNetworkResult.Success -> {
|
||||
val newEnabled = !currentlyEnabled
|
||||
RegistrationPreferences.registrationLockEnabled = newEnabled
|
||||
Log.i(TAG, "Registration lock ${if (newEnabled) "enabled" else "disabled"}")
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
registrationLockEnabled = newEnabled,
|
||||
toastMessage = if (newEnabled) "Registration lock enabled" else "Registration lock disabled"
|
||||
)
|
||||
}
|
||||
is RegistrationNetworkResult.Failure -> {
|
||||
Log.w(TAG, "Failed to toggle registration lock: ${result.error}")
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
toastMessage = "Failed to update registration lock"
|
||||
)
|
||||
}
|
||||
is RegistrationNetworkResult.NetworkError -> {
|
||||
Log.w(TAG, "Network error while toggling registration lock", result.exception)
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
toastMessage = "Network error. Please check your connection."
|
||||
)
|
||||
}
|
||||
is RegistrationNetworkResult.ApplicationError -> {
|
||||
Log.w(TAG, "Application error while toggling registration lock", result.exception)
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
toastMessage = "An error occurred: ${result.exception.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleTogglePinsOptOut() {
|
||||
val currentlyOptedOut = _state.value.pinsOptedOut
|
||||
val newOptedOut = !currentlyOptedOut
|
||||
|
||||
viewModelScope.launch {
|
||||
val attributes = NetworkController.AccountAttributes(
|
||||
signalingKey = null,
|
||||
registrationId = RegistrationPreferences.aciRegistrationId,
|
||||
voice = true,
|
||||
video = true,
|
||||
fetchesMessages = true,
|
||||
registrationLock = null,
|
||||
unidentifiedAccessKey = null,
|
||||
unrestrictedUnidentifiedAccess = false,
|
||||
discoverableByPhoneNumber = false,
|
||||
capabilities = NetworkController.AccountAttributes.Capabilities(
|
||||
storage = !newOptedOut,
|
||||
versionedExpirationTimer = true,
|
||||
attachmentBackfill = true,
|
||||
spqr = true
|
||||
),
|
||||
name = null,
|
||||
pniRegistrationId = RegistrationPreferences.pniRegistrationId,
|
||||
recoveryPassword = null
|
||||
)
|
||||
|
||||
when (val result = networkController.setAccountAttributes(attributes)) {
|
||||
is RegistrationNetworkResult.Success -> {
|
||||
RegistrationPreferences.pinsOptedOut = newOptedOut
|
||||
Log.i(TAG, "PINs opt-out ${if (newOptedOut) "enabled" else "disabled"}")
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
pinsOptedOut = newOptedOut,
|
||||
toastMessage = if (newOptedOut) "Opted out of PINs" else "Opted back into PINs"
|
||||
)
|
||||
}
|
||||
is RegistrationNetworkResult.Failure -> {
|
||||
Log.w(TAG, "Failed to toggle PINs opt-out: ${result.error}")
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
toastMessage = "Failed to update PIN settings"
|
||||
)
|
||||
}
|
||||
is RegistrationNetworkResult.NetworkError -> {
|
||||
Log.w(TAG, "Network error while toggling PINs opt-out", result.exception)
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
toastMessage = "Network error. Please check your connection."
|
||||
)
|
||||
}
|
||||
is RegistrationNetworkResult.ApplicationError -> {
|
||||
Log.w(TAG, "Application error while toggling PINs opt-out", result.exception)
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
toastMessage = "An error occurred: ${result.exception.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dismissMessage() {
|
||||
_state.value = _state.value.copy(toastMessage = null)
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val networkController: NetworkController,
|
||||
private val onBack: () -> Unit
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return PinSettingsViewModel(networkController, onBack) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,161 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.storage
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import org.signal.core.models.AccountEntropyPool
|
||||
import org.signal.core.models.MasterKey
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.models.ServiceId.PNI
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.signal.registration.NewRegistrationData
|
||||
import org.signal.registration.PreExistingRegistrationData
|
||||
|
||||
/**
|
||||
* SharedPreferences-based storage for registration data that doesn't need
|
||||
* the complexity of a SQLite database.
|
||||
*/
|
||||
object RegistrationPreferences {
|
||||
|
||||
private lateinit var context: Application
|
||||
|
||||
private const val PREFS_NAME = "registration_prefs"
|
||||
|
||||
private const val KEY_E164 = "e164"
|
||||
private const val KEY_ACI = "aci"
|
||||
private const val KEY_PNI = "pni"
|
||||
private const val KEY_SERVICE_PASSWORD = "service_password"
|
||||
private const val KEY_AEP = "aep"
|
||||
private const val KEY_PROFILE_KEY = "profile_key"
|
||||
private const val KEY_ACI_REGISTRATION_ID = "aci_registration_id"
|
||||
private const val KEY_PNI_REGISTRATION_ID = "pni_registration_id"
|
||||
private const val KEY_ACI_IDENTITY_KEY = "aci_identity_key"
|
||||
private const val KEY_PNI_IDENTITY_KEY = "pni_identity_key"
|
||||
private const val KEY_TEMPORARY_MASTER_KEY = "temporary_master_key"
|
||||
private const val KEY_REGISTRATION_LOCK_ENABLED = "registration_lock_enabled"
|
||||
private const val KEY_PIN = "has_pin"
|
||||
private const val KEY_PIN_ALPHANUMERIC = "pin_alphanumeric"
|
||||
private const val KEY_PINS_OPTED_OUT = "pins_opted_out"
|
||||
|
||||
fun init(context: Application) {
|
||||
this.context = context
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences by lazy {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
var e164: String?
|
||||
get() = prefs.getString(KEY_E164, null)
|
||||
set(value) = prefs.edit { putString(KEY_E164, value) }
|
||||
|
||||
var aci: ACI?
|
||||
get() = prefs.getString(KEY_ACI, null)?.let { ACI.parseOrNull(it) }
|
||||
set(value) = prefs.edit { putString(KEY_ACI, value?.toString()) }
|
||||
|
||||
var pni: PNI?
|
||||
get() = prefs.getString(KEY_PNI, null)?.let { PNI.parseOrNull(it) }
|
||||
set(value) = prefs.edit { putString(KEY_PNI, value?.toString()) }
|
||||
|
||||
var servicePassword: String?
|
||||
get() = prefs.getString(KEY_SERVICE_PASSWORD, null)
|
||||
set(value) = prefs.edit { putString(KEY_SERVICE_PASSWORD, value) }
|
||||
|
||||
var aep: AccountEntropyPool?
|
||||
get() = prefs.getString(KEY_AEP, null)?.let { AccountEntropyPool(it) }
|
||||
set(value) = prefs.edit { putString(KEY_AEP, value?.toString()) }
|
||||
|
||||
var profileKey: ProfileKey?
|
||||
get() = prefs.getString(KEY_PROFILE_KEY, null)?.let { ProfileKey(Base64.decode(it)) }
|
||||
set(value) = prefs.edit { putString(KEY_PROFILE_KEY, value?.let { Base64.encodeWithPadding(it.serialize()) }) }
|
||||
|
||||
var aciRegistrationId: Int
|
||||
get() = prefs.getInt(KEY_ACI_REGISTRATION_ID, -1)
|
||||
set(value) = prefs.edit { putInt(KEY_ACI_REGISTRATION_ID, value) }
|
||||
|
||||
var pniRegistrationId: Int
|
||||
get() = prefs.getInt(KEY_PNI_REGISTRATION_ID, -1)
|
||||
set(value) = prefs.edit { putInt(KEY_PNI_REGISTRATION_ID, value) }
|
||||
|
||||
var aciIdentityKeyPair: IdentityKeyPair?
|
||||
get() = prefs.getString(KEY_ACI_IDENTITY_KEY, null)?.let { IdentityKeyPair(Base64.decode(it)) }
|
||||
set(value) = prefs.edit { putString(KEY_ACI_IDENTITY_KEY, value?.let { Base64.encodeWithPadding(it.serialize()) }) }
|
||||
|
||||
var pniIdentityKeyPair: IdentityKeyPair?
|
||||
get() = prefs.getString(KEY_PNI_IDENTITY_KEY, null)?.let { IdentityKeyPair(Base64.decode(it)) }
|
||||
set(value) = prefs.edit { putString(KEY_PNI_IDENTITY_KEY, value?.let { Base64.encodeWithPadding(it.serialize()) }) }
|
||||
|
||||
val masterKey: MasterKey?
|
||||
get() = aep?.deriveMasterKey()
|
||||
|
||||
var temporaryMasterKey: MasterKey?
|
||||
get() = prefs.getString(KEY_TEMPORARY_MASTER_KEY, null)?.let { MasterKey(Base64.decode(it)) }
|
||||
set(value) = prefs.edit { putString(KEY_TEMPORARY_MASTER_KEY, value?.let { Base64.encodeWithPadding(it.serialize()) }) }
|
||||
|
||||
var registrationLockEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_REGISTRATION_LOCK_ENABLED, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_REGISTRATION_LOCK_ENABLED, value) }
|
||||
|
||||
val hasPin: Boolean
|
||||
get() = pin != null
|
||||
|
||||
var pin: String?
|
||||
get() = prefs.getString(KEY_PIN, null)
|
||||
set(value) = prefs.edit { putString(KEY_PIN, value) }
|
||||
|
||||
var pinAlphanumeric: Boolean
|
||||
get() = prefs.getBoolean(KEY_PIN_ALPHANUMERIC, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_PIN_ALPHANUMERIC, value) }
|
||||
|
||||
var pinsOptedOut: Boolean
|
||||
get() = prefs.getBoolean(KEY_PINS_OPTED_OUT, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_PINS_OPTED_OUT, value) }
|
||||
|
||||
fun saveRegistrationData(data: NewRegistrationData) {
|
||||
prefs.edit {
|
||||
putString(KEY_E164, data.e164)
|
||||
putString(KEY_ACI, data.aci.toString())
|
||||
putString(KEY_PNI, data.pni.toString())
|
||||
putString(KEY_SERVICE_PASSWORD, data.servicePassword)
|
||||
putString(KEY_AEP, data.aep.value)
|
||||
}
|
||||
}
|
||||
|
||||
fun getPreExistingRegistrationData(): PreExistingRegistrationData? {
|
||||
val e164 = e164 ?: return null
|
||||
val aci = aci ?: return null
|
||||
val pni = pni ?: return null
|
||||
val servicePassword = servicePassword ?: return null
|
||||
val aep = aep ?: return null
|
||||
|
||||
return PreExistingRegistrationData(
|
||||
e164 = e164,
|
||||
aci = aci,
|
||||
pni = pni,
|
||||
servicePassword = servicePassword,
|
||||
aep = aep
|
||||
)
|
||||
}
|
||||
|
||||
fun clearKeyMaterial() {
|
||||
prefs.edit {
|
||||
remove(KEY_PROFILE_KEY)
|
||||
remove(KEY_ACI_REGISTRATION_ID)
|
||||
remove(KEY_PNI_REGISTRATION_ID)
|
||||
remove(KEY_ACI_IDENTITY_KEY)
|
||||
remove(KEY_PNI_IDENTITY_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
prefs.edit { clear() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.sample.util
|
||||
|
||||
import org.signal.core.models.MasterKey
|
||||
import org.signal.registration.KeyMaterial
|
||||
import org.signal.registration.NetworkController
|
||||
import org.signal.registration.NewRegistrationData
|
||||
import org.signal.registration.PreExistingRegistrationData
|
||||
import org.signal.registration.RegistrationDependencies
|
||||
import org.signal.registration.StorageController
|
||||
import java.util.Locale
|
||||
|
||||
object PreviewRegistrationDependencies {
|
||||
fun get(): RegistrationDependencies {
|
||||
return RegistrationDependencies(
|
||||
networkController = PreviewNewtorkController(),
|
||||
storageController = PreviewStorageController()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class PreviewNewtorkController : NetworkController {
|
||||
override suspend fun createSession(
|
||||
e164: String,
|
||||
fcmToken: String?,
|
||||
mcc: String?,
|
||||
mnc: String?
|
||||
): NetworkController.RegistrationNetworkResult<NetworkController.SessionMetadata, NetworkController.CreateSessionError> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getSession(sessionId: String): NetworkController.RegistrationNetworkResult<NetworkController.SessionMetadata, NetworkController.GetSessionStatusError> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun updateSession(
|
||||
sessionId: String?,
|
||||
pushChallengeToken: String?,
|
||||
captchaToken: String?
|
||||
): NetworkController.RegistrationNetworkResult<NetworkController.SessionMetadata, NetworkController.UpdateSessionError> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun requestVerificationCode(
|
||||
sessionId: String,
|
||||
locale: Locale?,
|
||||
androidSmsRetrieverSupported: Boolean,
|
||||
transport: NetworkController.VerificationCodeTransport
|
||||
): NetworkController.RegistrationNetworkResult<NetworkController.SessionMetadata, NetworkController.RequestVerificationCodeError> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun submitVerificationCode(
|
||||
sessionId: String,
|
||||
verificationCode: String
|
||||
): NetworkController.RegistrationNetworkResult<NetworkController.SessionMetadata, NetworkController.SubmitVerificationCodeError> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun registerAccount(
|
||||
e164: String,
|
||||
password: String,
|
||||
sessionId: String?,
|
||||
recoveryPassword: String?,
|
||||
attributes: NetworkController.AccountAttributes,
|
||||
aciPreKeys: NetworkController.PreKeyCollection,
|
||||
pniPreKeys: NetworkController.PreKeyCollection,
|
||||
fcmToken: String?,
|
||||
skipDeviceTransfer: Boolean
|
||||
): NetworkController.RegistrationNetworkResult<NetworkController.RegisterAccountResponse, NetworkController.RegisterAccountError> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getFcmToken(): String? {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun awaitPushChallengeToken(): String? {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun getCaptchaUrl(): String {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun restoreMasterKeyFromSvr(
|
||||
svr2Credentials: NetworkController.SvrCredentials,
|
||||
pin: String
|
||||
): NetworkController.RegistrationNetworkResult<NetworkController.MasterKeyResponse, NetworkController.RestoreMasterKeyError> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun setPinAndMasterKeyOnSvr(
|
||||
pin: String,
|
||||
masterKey: MasterKey
|
||||
): NetworkController.RegistrationNetworkResult<Unit, NetworkController.BackupMasterKeyError> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun enableRegistrationLock(): NetworkController.RegistrationNetworkResult<Unit, NetworkController.SetRegistrationLockError> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun disableRegistrationLock(): NetworkController.RegistrationNetworkResult<Unit, NetworkController.SetRegistrationLockError> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getSvrCredentials(): NetworkController.RegistrationNetworkResult<NetworkController.SvrCredentials, NetworkController.GetSvrCredentialsError> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun setAccountAttributes(attributes: NetworkController.AccountAttributes): NetworkController.RegistrationNetworkResult<Unit, NetworkController.SetAccountAttributesError> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
|
||||
private class PreviewStorageController : StorageController {
|
||||
override suspend fun generateAndStoreKeyMaterial(): KeyMaterial {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun saveNewRegistrationData(newRegistrationData: NewRegistrationData) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun saveValidatedPinAndTemporaryMasterKey(pin: String, isAlphanumeric: Boolean, masterKey: MasterKey, registrationLockEnabled: Boolean) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun saveNewlyCreatedPin(pin: String, isAlphanumeric: Boolean) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun clearAllData() {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="258"
|
||||
android:viewportHeight="190.08008">
|
||||
<group android:scaleX="0.46"
|
||||
android:scaleY="0.33890247"
|
||||
android:translateX="69.66"
|
||||
android:translateY="62.830734">
|
||||
<group android:translateY="146.88005">
|
||||
<path android:pathData="M13.390625,-0L13.390625,-105.125L45.796875,-105.125Q55.15625,-105.125,62.203125,-101.296875Q69.265625,-97.484375,73.15625,-90.71875Q77.046875,-83.953125,77.046875,-74.875Q77.046875,-64.21875,71.5625,-56.578125Q66.09375,-48.953125,57.03125,-45.9375L78.484375,-0L64.21875,-0L43.625,-44.640625L26.203125,-44.640625L26.203125,-0L13.390625,-0ZM26.203125,-56.296875L45.796875,-56.296875Q53.859375,-56.296875,58.75,-61.40625Q63.640625,-66.53125,63.640625,-74.875Q63.640625,-83.375,58.75,-88.40625Q53.859375,-93.453125,45.796875,-93.453125L26.203125,-93.453125L26.203125,-56.296875Z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
<path android:pathData="M100.40625,0L100.40625,-105.125L160.875,-105.125L160.875,-93.3125L113.21875,-93.3125L113.21875,-60.765625L155.84375,-60.765625L155.84375,-49.25L113.21875,-49.25L113.21875,-11.8125L160.875,-11.8125L160.875,0L100.40625,0Z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
<path android:pathData="M215.78125,1.4375Q201.375,1.4375,193.01562,-6.625Q184.67188,-14.6875,184.67188,-28.796875L184.67188,-76.3125Q184.67188,-90.4375,193.01562,-98.5Q201.375,-106.5625,215.78125,-106.5625Q229.89062,-106.5625,238.23438,-98.421875Q246.59375,-90.28125,246.59375,-76.3125L233.625,-76.3125Q233.625,-85.25,228.9375,-90.0625Q224.26562,-94.890625,215.78125,-94.890625Q207.28125,-94.890625,202.45312,-90.140625Q197.625,-85.390625,197.625,-76.46875L197.625,-28.796875Q197.625,-19.875,202.45312,-14.96875Q207.28125,-10.078125,215.78125,-10.078125Q224.26562,-10.078125,228.9375,-14.96875Q233.625,-19.875,233.625,-28.796875L233.625,-43.203125L212.3125,-43.203125L212.3125,-55.015625L246.59375,-55.015625L246.59375,-28.796875Q246.59375,-14.828125,238.23438,-6.6875Q229.89062,1.4375,215.78125,1.4375Z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
BIN
demo/registration/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 650 B |
|
After Width: | Height: | Size: 1.5 KiB |
BIN
demo/registration/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 476 B |
|
After Width: | Height: | Size: 1002 B |
BIN
demo/registration/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 814 B |
|
After Width: | Height: | Size: 2.1 KiB |
BIN
demo/registration/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
BIN
demo/registration/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
BIN
demo/registration/src/main/res/raw/whisper.store
Normal file
10
demo/registration/src/main/res/values/firebase_messaging.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="google_app_id" translatable="false">1:312334754206:android:a9297b152879f266</string>
|
||||
<string name="gcm_defaultSenderId" translatable="false">312334754206</string>
|
||||
<string name="default_web_client_id" translatable="false">312334754206-dg1p1mtekis8ivja3ica50vonmrlunh4.apps.googleusercontent.com</string>
|
||||
<string name="firebase_database_url" translatable="false">https://api-project-312334754206.firebaseio.com</string>
|
||||
<string name="google_api_key" translatable="false">AIzaSyDrfzNAPBPzX6key51hqo3p5LZXF5Y-yxU</string>
|
||||
<string name="google_crash_reporting_api_key" translatable="false">AIzaSyDrfzNAPBPzX6key51hqo3p5LZXF5Y-yxU</string>
|
||||
<string name="project_id" translatable="false">api-project-312334754206</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#000000</color>
|
||||
</resources>
|
||||