diff --git a/app/src/main/kotlin/nl/eduid/graphs/MainGraph.kt b/app/src/main/kotlin/nl/eduid/graphs/MainGraph.kt index d276fda2..9ccab2e6 100644 --- a/app/src/main/kotlin/nl/eduid/graphs/MainGraph.kt +++ b/app/src/main/kotlin/nl/eduid/graphs/MainGraph.kt @@ -82,10 +82,10 @@ fun MainGraph( onSecurityClicked = { navController.navigate(Security.Settings.route) }, onEnrollWithQR = { navController.navigate(Account.ScanQR.routeForEnrol) }, launchOAuth = { navController.navigate(Graph.OAUTH) }, - goToRegistrationPinSetup = { challenge -> + goToRegistrationPinSetup = { challenge, isScanFlow -> val encodeChallenge = viewModel.encodeChallenge(challenge) navController.navigate( - "${Account.EnrollPinSetup.route}/$encodeChallenge" + "${Account.EnrollPinSetup.route}/$encodeChallenge/$isScanFlow" ) }, goToConfirmDeactivation = { phoneNumber -> @@ -106,9 +106,10 @@ fun MainGraph( val isEnrolment = entry.arguments?.getBoolean(Account.ScanQR.isEnrolment, false) ?: false ScanScreen(viewModel = viewModel, isEnrolment = isEnrolment, goBack = { navController.popBackStack() }, goToNext = { challenge -> val encodedChallenge = viewModel.encodeChallenge(challenge) + val isScanFlow = true if (challenge is EnrollmentChallenge) { navController.goToWithPopCurrent( - "${Account.EnrollPinSetup.route}/$encodedChallenge" + "${Account.EnrollPinSetup.route}/$encodedChallenge/$isScanFlow" ) } else { navController.goToWithPopCurrent( @@ -124,7 +125,8 @@ fun MainGraph( arguments = Account.EnrollPinSetup.arguments, ) { entry -> val viewModel = hiltViewModel(entry) - RegistrationPinSetupScreen(viewModel = viewModel, closePinSetupFlow = { navController.popBackStack() }) { nextStep -> + val isScanFlow = entry.arguments?.getBoolean(Account.EnrollPinSetup.isScanFlow, false) ?: false + RegistrationPinSetupScreen(viewModel = viewModel, isScanFlow = isScanFlow, closePinSetupFlow = { navController.popBackStack() }) { nextStep -> when (nextStep) { NextStep.RecoveryInBrowser -> { navController.navigate(Graph.CONTINUE_RECOVERY_IN_BROWSER) @@ -133,12 +135,17 @@ fun MainGraph( is NextStep.PromptBiometric -> { navController.navigate( WithChallenge.EnableBiometric.buildRouteForEnrolment( - encodedChallenge = viewModel.encodeChallenge(nextStep.challenge), pin = nextStep.pin + encodedChallenge = viewModel.encodeChallenge(nextStep.challenge), + pin = nextStep.pin, + isScanFlow = isScanFlow ) ) { popUpTo(Graph.HOME_PAGE) } } + is NextStep.Welcome -> { + navController.navigate(WelcomeStart.routeWithScanFlowArg(isScanFlow)) + } NextStep.Recovery -> navController.navigate(PhoneNumberRecovery.RequestCode.route) { popUpTo(Graph.HOME_PAGE) @@ -177,11 +184,14 @@ fun MainGraph( route = WithChallenge.EnableBiometric.routeWithArgs, arguments = WithChallenge.arguments ) { entry -> val viewModel = hiltViewModel(entry) + val isScanFlow = entry.arguments?.getBoolean(WithChallenge.isScanFlowArg, false) ?: false EnableBiometricScreen(viewModel = viewModel, goToNext = { shouldAskForRecovery -> if (shouldAskForRecovery) { navController.navigate(PhoneNumberRecovery.RequestCode.route) { popUpTo(Graph.HOME_PAGE) } + } else if (isScanFlow) { + navController.navigate(WelcomeStart.routeWithScanFlowArg(true)) } else { //Continue recovery via web navController.navigate(Graph.CONTINUE_RECOVERY_IN_BROWSER) @@ -275,7 +285,7 @@ fun MainGraph( if (isDeactivation) { navController.popBackStack() } else { - navController.navigate(Graph.WELCOME_START) { + navController.navigate(WelcomeStart.routeWithScanFlowArg(false)) { //Flow for phone number recovery completed, remove from stack entirely popUpTo(PhoneNumberRecovery.RequestCode.route) { inclusive = true } } @@ -285,14 +295,17 @@ fun MainGraph( //endregion //region Welcome-FirstTime - composable(Graph.WELCOME_START) { entry -> + composable( + route = WelcomeStart.routeWithArgs, arguments = WelcomeStart.arguments + ) { entry -> val viewModel = hiltViewModel(entry) + val isScanFlow = WelcomeStart.decodeScanFlowArg(entry) WelcomeStartScreen( viewModel, ) { accountIsAlreadyLinked -> - if (accountIsAlreadyLinked) { + if (accountIsAlreadyLinked || isScanFlow) { navController.navigate(Graph.HOME_PAGE) { - //Clear existing home page that has no account + // Clear existing home page that has no account popUpTo(Graph.HOME_PAGE) { inclusive = true } diff --git a/app/src/main/kotlin/nl/eduid/graphs/Routes.kt b/app/src/main/kotlin/nl/eduid/graphs/Routes.kt index c5d0b8d4..2faf1040 100644 --- a/app/src/main/kotlin/nl/eduid/graphs/Routes.kt +++ b/app/src/main/kotlin/nl/eduid/graphs/Routes.kt @@ -13,7 +13,6 @@ object Graph { const val REQUEST_EDU_ID_ACCOUNT = "request_edu_id_account" const val REQUEST_EDU_ID_FORM = "request_edu_id_details" - const val WELCOME_START = "start" const val FIRST_TIME_DIALOG = "first_time_dialog" const val CONTINUE_RECOVERY_IN_BROWSER = "continue_recovery_in_browser" const val PERSONAL_INFO = "personal_info" @@ -126,6 +125,26 @@ sealed class PhoneNumberRecovery(val route: String) { } } +object WelcomeStart { + private const val route = "welcome_start" + const val isScanFlowArg = "is_scan_flow" + const val routeWithArgs = "$route/{$isScanFlowArg}" + val arguments = listOf(navArgument(isScanFlowArg) { + type = NavType.BoolType + nullable = false + defaultValue = false + }) + + fun routeWithScanFlowArg(isScanFlow: Boolean) = + "${route}/$isScanFlow" + + fun decodeScanFlowArg(entry: NavBackStackEntry): Boolean { + return entry.arguments?.getBoolean(isScanFlowArg, false) ?: false + } + +} + + sealed class Account(val route: String) { object ScanQR : Account("scan") { @@ -143,12 +162,17 @@ sealed class Account(val route: String) { object EnrollPinSetup : Account("enroll_pin_setup") { const val enrollChallenge = "enroll_challenge_arg" + const val isScanFlow = "is_scan_flow" - val routeWithArgs = "$route/{$enrollChallenge}" + val routeWithArgs = "$route/{$enrollChallenge}/{$isScanFlow}" val arguments = listOf(navArgument(enrollChallenge) { type = NavType.StringType nullable = false defaultValue = "" + }, navArgument(isScanFlow) { + type = NavType.BoolType + nullable = false + defaultValue = false }) } @@ -226,8 +250,9 @@ sealed class WithChallenge(val route: String) { const val challengeArg = "challenge_arg" const val pinArg = "pin_arg" const val isEnrolmentArg = "is_enrolment_arg" + const val isScanFlowArg = "is_scan_flow_arg" - const val args = "{$challengeArg}/{$pinArg}/{$isEnrolmentArg}" + const val args = "{$challengeArg}/{$pinArg}/{$isEnrolmentArg}/{$isScanFlowArg}" val arguments = listOf(navArgument(challengeArg) { type = NavType.StringType nullable = false @@ -240,17 +265,21 @@ sealed class WithChallenge(val route: String) { type = NavType.BoolType nullable = false defaultValue = true + }, navArgument(isScanFlowArg) { + type = NavType.BoolType + nullable = false + defaultValue = false }) } object EnableBiometric : WithChallenge("enable_biometric") { val routeWithArgs = "$route/$args" - fun buildRouteForEnrolment(encodedChallenge: String, pin: String): String = - "$route/$encodedChallenge/$pin/true" + fun buildRouteForEnrolment(encodedChallenge: String, pin: String, isScanFlow: Boolean): String = + "$route/$encodedChallenge/$pin/true/$isScanFlow" @SuppressWarnings("unused") - fun buildRouteForAuthentication(encodedChallenge: String, pin: String): String = - "$route/$encodedChallenge/$pin/false" + fun buildRouteForAuthentication(encodedChallenge: String, pin: String, isScanFlow: Boolean): String = + "$route/$encodedChallenge/$pin/false/$isScanFlow" } } diff --git a/app/src/main/kotlin/nl/eduid/screens/homepage/HomePageNoAccount.kt b/app/src/main/kotlin/nl/eduid/screens/homepage/HomePageNoAccount.kt index 65ca5e51..56b66b12 100644 --- a/app/src/main/kotlin/nl/eduid/screens/homepage/HomePageNoAccount.kt +++ b/app/src/main/kotlin/nl/eduid/screens/homepage/HomePageNoAccount.kt @@ -66,7 +66,7 @@ fun HomePageNoAccountContent( onGoToScan: () -> Unit = {}, onGoToRequestEduId: () -> Unit = {}, onGoToSignIn: () -> Unit = {}, - onGoToRegistrationPinSetup: (EnrollmentChallenge) -> Unit = {}, + onGoToRegistrationPinSetup: (EnrollmentChallenge, /* isScanFlow: */ Boolean) -> Unit = { _, _ -> }, onGoToConfirmDeactivation: (String) -> Unit = {}, ) { val sheetState = rememberModalBottomSheetState() @@ -181,7 +181,7 @@ fun HomePageNoAccountContent( onGoToRegistrationPinSetup ) LaunchedEffect(viewModel.uiState) { - currentGoToRegistrationPinSetup(viewModel.uiState.currentChallenge as EnrollmentChallenge) + currentGoToRegistrationPinSetup(viewModel.uiState.currentChallenge as EnrollmentChallenge, /* isScanFlow = */ false) viewModel.clearCurrentChallenge() waitingForVmEvent = false } diff --git a/app/src/main/kotlin/nl/eduid/screens/homepage/HomePageScreen.kt b/app/src/main/kotlin/nl/eduid/screens/homepage/HomePageScreen.kt index 7691ef90..4c11c069 100644 --- a/app/src/main/kotlin/nl/eduid/screens/homepage/HomePageScreen.kt +++ b/app/src/main/kotlin/nl/eduid/screens/homepage/HomePageScreen.kt @@ -13,7 +13,7 @@ fun HomePageScreen( onSecurityClicked: () -> Unit, onEnrollWithQR: () -> Unit, launchOAuth: () -> Unit, - goToRegistrationPinSetup: (EnrollmentChallenge) -> Unit, + goToRegistrationPinSetup: (EnrollmentChallenge, Boolean) -> Unit, goToConfirmDeactivation: (String) -> Unit, onGoToRequestEduIdAccount: () -> Unit, ) { diff --git a/app/src/main/kotlin/nl/eduid/screens/pinsetup/RegistrationPinSetupScreen.kt b/app/src/main/kotlin/nl/eduid/screens/pinsetup/RegistrationPinSetupScreen.kt index 4e57d36c..085a8541 100644 --- a/app/src/main/kotlin/nl/eduid/screens/pinsetup/RegistrationPinSetupScreen.kt +++ b/app/src/main/kotlin/nl/eduid/screens/pinsetup/RegistrationPinSetupScreen.kt @@ -26,8 +26,9 @@ import nl.eduid.ui.EduIdTopAppBar @Composable fun RegistrationPinSetupScreen( viewModel: RegistrationPinSetupViewModel, + isScanFlow: Boolean, closePinSetupFlow: () -> Unit, - goToNextStep: (NextStep) -> Unit, + goToNextStep: (NextStep) -> Unit ) { BackHandler { viewModel.handleBackNavigation(closePinSetupFlow) } //Because the same screen is being used for creating the PIN as well as confirming the PIN @@ -41,6 +42,7 @@ fun RegistrationPinSetupScreen( uiState = viewModel.uiState, padding = it, goToNextStep = goToNextStep, + isScanFlow = isScanFlow, viewModel = viewModel, ) } @@ -50,6 +52,7 @@ fun RegistrationPinSetupScreen( private fun RegistrationPinSetupContent( uiState: UiState, padding: PaddingValues = PaddingValues(), + isScanFlow: Boolean, goToNextStep: (NextStep) -> Unit = {}, viewModel: RegistrationPinSetupViewModel, ) { @@ -126,7 +129,7 @@ private fun RegistrationPinSetupContent( viewModel.onPinChange(pin, step) }, onClick = { - viewModel.submitPin(context, uiState.pinStep) + viewModel.submitPin(context, isScanFlow, uiState.pinStep) enrollmentInProgress = uiState.pinStep == PinStep.PinConfirm }, isProcessing = uiState.isProcessing diff --git a/app/src/main/kotlin/nl/eduid/screens/pinsetup/RegistrationPinSetupViewModel.kt b/app/src/main/kotlin/nl/eduid/screens/pinsetup/RegistrationPinSetupViewModel.kt index c9c04625..c4cc6658 100644 --- a/app/src/main/kotlin/nl/eduid/screens/pinsetup/RegistrationPinSetupViewModel.kt +++ b/app/src/main/kotlin/nl/eduid/screens/pinsetup/RegistrationPinSetupViewModel.kt @@ -68,7 +68,7 @@ class RegistrationPinSetupViewModel @Inject constructor( } } - fun submitPin(context: Context, currentStep: PinStep) { + fun submitPin(context: Context, isScanFlow: Boolean, currentStep: PinStep) { uiState = uiState.copy(isProcessing = true) if (currentStep is PinStep.PinCreate) { val createdPin = uiState.pinValue @@ -86,14 +86,14 @@ class RegistrationPinSetupViewModel @Inject constructor( val createdPin = uiState.pinValue val pinConfirmed = confirmPin == createdPin if (pinConfirmed) { - enroll(context, createdPin) + enroll(context, isScanFlow, createdPin) } else { uiState = uiState.copy(isPinInvalid = true, isProcessing = false) } } } - private fun enroll(context: Context, password: String) = viewModelScope.launch { + private fun enroll(context: Context, isScanFlow: Boolean, password: String) = viewModelScope.launch { val currentChallenge = challenge ?: return@launch val result = enrollRepository.completeChallenge( @@ -113,7 +113,7 @@ class RegistrationPinSetupViewModel @Inject constructor( } ChallengeCompleteResult.Success -> { - val nextStep = calculateNextStep(context, currentChallenge) + val nextStep = calculateNextStep(context, isScanFlow, currentChallenge) uiState = uiState.copy( nextStep = nextStep, @@ -125,6 +125,7 @@ class RegistrationPinSetupViewModel @Inject constructor( private suspend fun calculateNextStep( context: Context, + isScanFlow: Boolean, currentChallenge: EnrollmentChallenge, ): NextStep { return if (context.biometricUsable() && currentChallenge.identity.biometricOfferUpgrade) { @@ -134,6 +135,8 @@ class RegistrationPinSetupViewModel @Inject constructor( checkRecovery.shouldAppDoRecoveryForIdentity(currentChallenge.identity.identifier) if (shouldAppDoRecovery) { NextStep.Recovery + } else if (isScanFlow) { + NextStep.Welcome } else { NextStep.RecoveryInBrowser } diff --git a/app/src/main/kotlin/nl/eduid/screens/pinsetup/UiState.kt b/app/src/main/kotlin/nl/eduid/screens/pinsetup/UiState.kt index 6fe9bd98..9cd46b4d 100644 --- a/app/src/main/kotlin/nl/eduid/screens/pinsetup/UiState.kt +++ b/app/src/main/kotlin/nl/eduid/screens/pinsetup/UiState.kt @@ -17,4 +17,5 @@ sealed class NextStep { data class PromptBiometric(val challenge: Challenge, val pin: String) : NextStep() object Recovery : NextStep() object RecoveryInBrowser : NextStep() + object Welcome: NextStep() } \ No newline at end of file diff --git a/app/src/main/kotlin/nl/eduid/screens/start/WelcomeStartScreen.kt b/app/src/main/kotlin/nl/eduid/screens/start/WelcomeStartScreen.kt index 67714ee5..ce78cf28 100644 --- a/app/src/main/kotlin/nl/eduid/screens/start/WelcomeStartScreen.kt +++ b/app/src/main/kotlin/nl/eduid/screens/start/WelcomeStartScreen.kt @@ -1,5 +1,10 @@ package nl.eduid.screens.start +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -16,10 +21,13 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle @@ -30,10 +38,12 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout +import androidx.core.content.ContextCompat import nl.eduid.R import nl.eduid.ui.EduIdTopAppBar import nl.eduid.ui.PrimaryButton import nl.eduid.ui.theme.EduidAppAndroidTheme +import timber.log.Timber @Composable fun WelcomeStartScreen( @@ -55,6 +65,27 @@ private fun WelcomeStartContent( padding: PaddingValues = PaddingValues(), onNext: () -> Unit = {}, ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + val context = LocalContext.current + val notificationPermission = Manifest.permission.POST_NOTIFICATIONS + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + Timber.i("Notification permission granted") + } else { + Timber.d("Showing notification permission dialog") + } + } + LaunchedEffect(lifecycle){ + if (ContextCompat.checkSelfPermission(context, notificationPermission) != PackageManager.PERMISSION_GRANTED) { + permissionLauncher.launch(notificationPermission) + } + } + } + + ConstraintLayout( modifier = Modifier .fillMaxSize()