diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index f77453f334..e3431d7adb 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -4618,6 +4618,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -4749,6 +4757,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -4791,6 +4807,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -5114,6 +5138,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -5141,6 +5173,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -5374,6 +5414,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -5749,6 +5797,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -5770,6 +5826,9 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + @@ -6067,6 +6126,9 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + @@ -6410,6 +6472,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -6851,6 +6921,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -6879,6 +6957,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + diff --git a/registration/app/build.gradle.kts b/registration/app/build.gradle.kts index 796167f55d..a7fd765eee 100644 --- a/registration/app/build.gradle.kts +++ b/registration/app/build.gradle.kts @@ -82,6 +82,9 @@ dependencies { 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) diff --git a/registration/app/src/main/java/org/signal/registration/sample/AppViewModel.kt b/registration/app/src/main/java/org/signal/registration/sample/AppViewModel.kt deleted file mode 100644 index ada2bfe25c..0000000000 --- a/registration/app/src/main/java/org/signal/registration/sample/AppViewModel.kt +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright 2025 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.registration.sample - -import androidx.lifecycle.ViewModel -import org.signal.core.ui.navigation.ResultEventBus - -class AppViewModel : ViewModel() { - val resultEventBus = ResultEventBus() -} diff --git a/registration/app/src/main/java/org/signal/registration/sample/MainActivity.kt b/registration/app/src/main/java/org/signal/registration/sample/MainActivity.kt index de8b4f5e1c..d830350bd3 100644 --- a/registration/app/src/main/java/org/signal/registration/sample/MainActivity.kt +++ b/registration/app/src/main/java/org/signal/registration/sample/MainActivity.kt @@ -3,13 +3,13 @@ * 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.activity.result.ActivityResultLauncher -import androidx.activity.viewModels import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween @@ -39,15 +39,12 @@ 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.core.ui.navigation.ResultEffect -import org.signal.core.ui.navigation.ResultEventBus -import org.signal.registration.NetworkController -import org.signal.registration.RegistrationActivity import org.signal.registration.RegistrationDependencies -import org.signal.registration.StorageController -import org.signal.registration.sample.MainActivity.Companion.REGISTRATION_RESULT +import org.signal.registration.RegistrationNavHost +import org.signal.registration.RegistrationRepository import org.signal.registration.sample.screens.RegistrationCompleteScreen import org.signal.registration.sample.screens.main.MainScreen import org.signal.registration.sample.screens.main.MainScreenViewModel @@ -89,6 +86,9 @@ sealed interface SampleRoute : NavKey { @Serializable data object Main : SampleRoute + @Serializable + data object Registration : SampleRoute + @Serializable data object RegistrationComplete : SampleRoute @@ -104,15 +104,14 @@ class MainActivity : ComponentActivity() { const val REGISTRATION_RESULT = "registration_result" } - private val viewModel: AppViewModel by viewModels() - - private val registrationLauncher: ActivityResultLauncher = registerForActivityResult(RegistrationActivity.RegistrationContract()) { success -> - viewModel.resultEventBus.sendResult(REGISTRATION_RESULT, success) - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val registrationRepository = RegistrationRepository( + networkController = RegistrationDependencies.get().networkController, + storageController = RegistrationDependencies.get().storageController + ) + setContent { SignalTheme { Surface( @@ -122,11 +121,9 @@ class MainActivity : ComponentActivity() { val backStack = rememberNavBackStack(SampleRoute.Main) SampleNavHost( - onLaunchRegistration = { registrationLauncher.launch(Unit) }, backStack = backStack, - resultEventBus = viewModel.resultEventBus, - storageController = RegistrationDependencies.get().storageController, - networkController = RegistrationDependencies.get().networkController, + registrationRepository = registrationRepository, + registrationDependencies = RegistrationDependencies.get(), onStartOver = { backStack.clear() backStack.add(SampleRoute.Main) @@ -140,20 +137,18 @@ class MainActivity : ComponentActivity() { @Composable private fun SampleNavHost( - onLaunchRegistration: () -> Unit, onStartOver: () -> Unit, + registrationRepository: RegistrationRepository, + registrationDependencies: RegistrationDependencies, backStack: NavBackStack, - resultEventBus: ResultEventBus, - storageController: StorageController, - networkController: NetworkController, modifier: Modifier = Modifier ) { val entryProvider: (NavKey) -> NavEntry = entryProvider { entry { val viewModel: MainScreenViewModel = viewModel( factory = MainScreenViewModel.Factory( - storageController = storageController, - onLaunchRegistration = onLaunchRegistration, + storageController = registrationDependencies.storageController, + onLaunchRegistration = { backStack.add(SampleRoute.Registration) }, onOpenPinSettings = { backStack.add(SampleRoute.PinSettings) } ) ) @@ -164,19 +159,22 @@ private fun SampleNavHost( onPauseOrDispose { } } - ResultEffect(resultEventBus, REGISTRATION_RESULT) { success -> - if (success) { - viewModel.refreshData() - backStack.add(SampleRoute.RegistrationComplete) - } - } - MainScreen( state = state, onEvent = { viewModel.onEvent(it) } ) } + entry { + RegistrationNavHost( + registrationRepository, + modifier = Modifier.fillMaxSize(), + onRegistrationComplete = { + backStack.add(SampleRoute.RegistrationComplete) + } + ) + } + entry { RegistrationCompleteScreen(onStartOver = onStartOver) } @@ -186,7 +184,7 @@ private fun SampleNavHost( ) { val viewModel: PinSettingsViewModel = viewModel( factory = PinSettingsViewModel.Factory( - networkController = networkController, + networkController = registrationDependencies.networkController, onBack = { backStack.removeLastOrNull() } ) ) diff --git a/registration/lib/src/main/java/org/signal/registration/RegistrationActivity.kt b/registration/lib/src/main/java/org/signal/registration/RegistrationActivity.kt index 89d674c3de..f31fa8ae97 100644 --- a/registration/lib/src/main/java/org/signal/registration/RegistrationActivity.kt +++ b/registration/lib/src/main/java/org/signal/registration/RegistrationActivity.kt @@ -7,11 +7,11 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContract import androidx.activity.viewModels +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.rememberMultiplePermissionsState import org.signal.core.ui.compose.theme.SignalTheme -import org.signal.registration.screens.RegistrationHostScreen /** * Activity entry point for the registration flow. @@ -39,16 +39,11 @@ class RegistrationActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContent { - val permissionsState = rememberMultiplePermissionsState( - permissions = viewModel.getRequiredPermissions() - ) - SignalTheme(incognitoKeyboardEnabled = false) { Surface { - RegistrationHostScreen( + RegistrationNavHost( registrationRepository = repository, - viewModel = viewModel, - permissionsState = permissionsState, + modifier = Modifier.fillMaxSize(), onRegistrationComplete = { setResult(RESULT_OK) finish() diff --git a/registration/lib/src/main/java/org/signal/registration/RegistrationNavigation.kt b/registration/lib/src/main/java/org/signal/registration/RegistrationNavigation.kt index 91ce71dcf1..fd07d11aa9 100644 --- a/registration/lib/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/registration/lib/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +@file:OptIn(ExperimentalPermissionsApi::class) + package org.signal.registration import android.os.Parcelable @@ -15,7 +17,6 @@ import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -27,6 +28,7 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.MultiplePermissionsState +import com.google.accompanist.permissions.rememberMultiplePermissionsState import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import org.signal.core.ui.navigation.ResultEffect @@ -48,6 +50,8 @@ import org.signal.registration.screens.pinentry.PinEntryScreen import org.signal.registration.screens.restore.RestoreViaQrScreen import org.signal.registration.screens.restore.RestoreViaQrScreenEvents import org.signal.registration.screens.restore.RestoreViaQrState +import org.signal.registration.screens.util.navigateBack +import org.signal.registration.screens.util.navigateTo import org.signal.registration.screens.verificationcode.VerificationCodeScreen import org.signal.registration.screens.verificationcode.VerificationCodeViewModel import org.signal.registration.screens.welcome.WelcomeScreen @@ -113,8 +117,9 @@ private const val CAPTCHA_RESULT = "captcha_token" /** * Sets up the navigation graph for the registration flow using Navigation 3. * - * @param registrationViewModel The shared ViewModel for the registration flow. - * @param permissionsState The permissions state managed at the activity level. + * @param registrationRepository The repository for registration data. + * @param registrationViewModel Optional ViewModel for testing. If null, creates one internally. + * @param permissionsState Optional permissions state for testing. If null, creates one internally. * @param modifier Modifier to be applied to the NavDisplay. * @param onRegistrationComplete Callback invoked when registration is successfully completed. */ @@ -122,20 +127,23 @@ private const val CAPTCHA_RESULT = "captcha_token" @Composable fun RegistrationNavHost( registrationRepository: RegistrationRepository, - registrationViewModel: RegistrationViewModel, - permissionsState: MultiplePermissionsState, + registrationViewModel: RegistrationViewModel? = null, + permissionsState: MultiplePermissionsState? = null, modifier: Modifier = Modifier, onRegistrationComplete: () -> Unit = {} ) { - val registrationState by registrationViewModel.state.collectAsStateWithLifecycle() - val navigator = remember { RegistrationNavigator(eventEmitter = registrationViewModel::onEvent) } + val viewModel: RegistrationViewModel = registrationViewModel ?: viewModel( + factory = RegistrationViewModel.Factory(registrationRepository) + ) + + val registrationState by viewModel.state.collectAsStateWithLifecycle() + val permissions: MultiplePermissionsState = permissionsState ?: rememberMultiplePermissionsState(viewModel.getRequiredPermissions()) val entryProvider = entryProvider { - registrationEntries( + navigationEntries( registrationRepository = registrationRepository, - registrationViewModel = registrationViewModel, - permissionsState = permissionsState, - navigator = navigator, + registrationViewModel = viewModel, + permissionsState = permissions, onRegistrationComplete = onRegistrationComplete ) } @@ -152,7 +160,7 @@ fun RegistrationNavHost( NavDisplay( entries = entries, - onBack = { registrationViewModel.onEvent(RegistrationFlowEvent.NavigateBack) }, + onBack = { viewModel.onEvent(RegistrationFlowEvent.NavigateBack) }, modifier = modifier, transitionSpec = { // Slide in from right and fade in when navigating forward @@ -204,25 +212,22 @@ fun RegistrationNavHost( ) } -/** - * Defines all navigation entries for the registration flow. - */ -@OptIn(ExperimentalPermissionsApi::class) -private fun EntryProviderScope.registrationEntries( +private fun EntryProviderScope.navigationEntries( registrationRepository: RegistrationRepository, registrationViewModel: RegistrationViewModel, permissionsState: MultiplePermissionsState, - navigator: RegistrationNavigator, onRegistrationComplete: () -> Unit ) { + val parentEventEmitter: (RegistrationFlowEvent) -> Unit = registrationViewModel::onEvent + // --- Welcome Screen entry { WelcomeScreen( onEvent = { event -> when (event) { - WelcomeScreenEvents.Continue -> navigator.navigate(RegistrationRoute.Permissions(forRestore = false)) - WelcomeScreenEvents.DoesNotHaveOldPhone -> navigator.navigate(RegistrationRoute.Restore) - WelcomeScreenEvents.HasOldPhone -> navigator.navigate(RegistrationRoute.Permissions(forRestore = true)) + WelcomeScreenEvents.Continue -> parentEventEmitter.navigateTo(RegistrationRoute.Permissions(forRestore = false)) + WelcomeScreenEvents.DoesNotHaveOldPhone -> parentEventEmitter.navigateTo(RegistrationRoute.Restore) + WelcomeScreenEvents.HasOldPhone -> parentEventEmitter.navigateTo(RegistrationRoute.Permissions(forRestore = true)) } } ) @@ -234,9 +239,9 @@ private fun EntryProviderScope.registrationEntries( permissionsState = permissionsState, onProceed = { if (key.forRestore) { - navigator.navigate(RegistrationRoute.RestoreViaQr) + parentEventEmitter.navigateTo(RegistrationRoute.RestoreViaQr) } else { - navigator.navigate(RegistrationRoute.PhoneNumberEntry) + parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberEntry) } } ) @@ -281,10 +286,10 @@ private fun EntryProviderScope.registrationEntries( when (event) { is CaptchaScreenEvents.CaptchaCompleted -> { registrationViewModel.resultBus.sendResult(CAPTCHA_RESULT, event.token) - navigator.goBack() + parentEventEmitter.navigateBack() } CaptchaScreenEvents.Cancel -> { - navigator.goBack() + parentEventEmitter.navigateBack() } } } @@ -370,7 +375,7 @@ private fun EntryProviderScope.registrationEntries( when (event) { AccountLockedScreenEvents.Next -> { // TODO: Navigate to appropriate next screen (likely back to welcome or phone entry) - navigator.navigate(RegistrationRoute.Welcome) + parentEventEmitter.navigateTo(RegistrationRoute.Welcome) } AccountLockedScreenEvents.LearnMore -> { // TODO: Open learn more URL @@ -393,7 +398,7 @@ private fun EntryProviderScope.registrationEntries( // TODO: Retry QR code generation } RestoreViaQrScreenEvents.Cancel -> { - navigator.goBack() + parentEventEmitter.navigateBack() } RestoreViaQrScreenEvents.UseProxy -> { // TODO: Navigate to proxy settings @@ -420,20 +425,3 @@ private fun EntryProviderScope.registrationEntries( } } } - -/** - * Navigator for the registration flow. - * Handles navigation events by updating the back stack. - */ -private class RegistrationNavigator( - private val eventEmitter: (RegistrationFlowEvent) -> Unit -) { - - fun navigate(route: RegistrationRoute) { - eventEmitter(RegistrationFlowEvent.NavigateToScreen(route)) - } - - fun goBack() { - eventEmitter(RegistrationFlowEvent.NavigateBack) - } -} diff --git a/registration/lib/src/main/java/org/signal/registration/screens/RegistrationHostScreen.kt b/registration/lib/src/main/java/org/signal/registration/screens/RegistrationHostScreen.kt deleted file mode 100644 index e30748cbd7..0000000000 --- a/registration/lib/src/main/java/org/signal/registration/screens/RegistrationHostScreen.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2025 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.registration.screens - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.MultiplePermissionsState -import org.signal.registration.RegistrationNavHost -import org.signal.registration.RegistrationRepository -import org.signal.registration.RegistrationViewModel - -/** - * Entry point for the registration flow. - * - * This composable sets up the entire registration navigation flow and can be - * embedded into the main app's navigation or launched as a standalone flow. - * - * @param viewModel The shared ViewModel for the registration flow. - * @param permissionsState The permissions state managed at the activity level. - * @param modifier Modifier to be applied to the root container. - * @param onRegistrationComplete Callback invoked when the registration process is successfully completed. - */ -@OptIn(ExperimentalPermissionsApi::class) -@Composable -fun RegistrationHostScreen( - registrationRepository: RegistrationRepository, - viewModel: RegistrationViewModel, - permissionsState: MultiplePermissionsState, - modifier: Modifier = Modifier, - onRegistrationComplete: () -> Unit = {} -) { - RegistrationNavHost( - registrationRepository = registrationRepository, - registrationViewModel = viewModel, - permissionsState = permissionsState, - modifier = modifier.fillMaxSize(), - onRegistrationComplete = onRegistrationComplete - ) -}