From 9ad8372d45c732bacdab74df09f09ce6d9bb7eff Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 16:05:26 +0300 Subject: [PATCH] android: add verify and reset auth flows with deep link routing --- android/app/src/main/AndroidManifest.xml | 8 ++ .../ru/daemonlord/messenger/MainActivity.kt | 32 +++++++ .../messenger/ui/auth/LoginScreen.kt | 23 +++-- .../ui/auth/reset/ResetPasswordScreen.kt | 87 +++++++++++++++++++ .../ui/auth/verify/VerifyEmailScreen.kt | 77 ++++++++++++++++ .../messenger/ui/navigation/AppNavGraph.kt | 60 ++++++++++++- 6 files changed, 278 insertions(+), 9 deletions(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/ui/auth/reset/ResetPasswordScreen.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/ui/auth/verify/VerifyEmailScreen.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 31e4493..554c6d6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -28,6 +28,14 @@ android:host="chat.daemonlord.ru" android:pathPrefix="/join" android:scheme="https" /> + + (null) + private var pendingVerifyEmailToken by mutableStateOf(null) + private var pendingResetPasswordToken by mutableStateOf(null) private var pendingNotificationChatId by mutableStateOf(null) private var pendingNotificationMessageId by mutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) pendingInviteToken = intent.extractInviteToken() + pendingVerifyEmailToken = intent.extractVerifyEmailToken() + pendingResetPasswordToken = intent.extractResetPasswordToken() val notificationPayload = intent.extractNotificationOpenPayload() pendingNotificationChatId = notificationPayload?.first pendingNotificationMessageId = notificationPayload?.second @@ -36,6 +40,10 @@ class MainActivity : ComponentActivity() { AppRoot( inviteToken = pendingInviteToken, onInviteTokenConsumed = { pendingInviteToken = null }, + verifyEmailToken = pendingVerifyEmailToken, + onVerifyEmailTokenConsumed = { pendingVerifyEmailToken = null }, + resetPasswordToken = pendingResetPasswordToken, + onResetPasswordTokenConsumed = { pendingResetPasswordToken = null }, notificationChatId = pendingNotificationChatId, notificationMessageId = pendingNotificationMessageId, onNotificationConsumed = { @@ -52,6 +60,8 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) pendingInviteToken = intent.extractInviteToken() ?: pendingInviteToken + pendingVerifyEmailToken = intent.extractVerifyEmailToken() ?: pendingVerifyEmailToken + pendingResetPasswordToken = intent.extractResetPasswordToken() ?: pendingResetPasswordToken val notificationPayload = intent.extractNotificationOpenPayload() if (notificationPayload != null) { pendingNotificationChatId = notificationPayload.first @@ -64,6 +74,10 @@ class MainActivity : ComponentActivity() { private fun AppRoot( inviteToken: String?, onInviteTokenConsumed: () -> Unit, + verifyEmailToken: String?, + onVerifyEmailTokenConsumed: () -> Unit, + resetPasswordToken: String?, + onResetPasswordTokenConsumed: () -> Unit, notificationChatId: Long?, notificationMessageId: Long?, onNotificationConsumed: () -> Unit, @@ -71,12 +85,30 @@ private fun AppRoot( MessengerNavHost( inviteToken = inviteToken, onInviteTokenConsumed = onInviteTokenConsumed, + verifyEmailToken = verifyEmailToken, + onVerifyEmailTokenConsumed = onVerifyEmailTokenConsumed, + resetPasswordToken = resetPasswordToken, + onResetPasswordTokenConsumed = onResetPasswordTokenConsumed, notificationChatId = notificationChatId, notificationMessageId = notificationMessageId, onNotificationConsumed = onNotificationConsumed, ) } +private fun Intent?.extractVerifyEmailToken(): String? { + val uri = this?.data ?: return null + val isVerifyPath = uri.pathSegments.contains("verify-email") || uri.path.equals("/verify-email", ignoreCase = true) + if (!isVerifyPath) return null + return uri.getQueryParameter("token")?.trim()?.takeIf { it.isNotBlank() } +} + +private fun Intent?.extractResetPasswordToken(): String? { + val uri = this?.data ?: return null + val isResetPath = uri.pathSegments.contains("reset-password") || uri.path.equals("/reset-password", ignoreCase = true) + if (!isResetPath) return null + return uri.getQueryParameter("token")?.trim()?.takeIf { it.isNotBlank() } +} + private fun Intent?.extractInviteToken(): String? { val uri = this?.data ?: return null val queryToken = uri.getQueryParameter("token")?.trim().orEmpty() diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt index 83b2157..569d936 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt @@ -26,6 +26,8 @@ fun LoginScreen( onEmailChanged: (String) -> Unit, onPasswordChanged: (String) -> Unit, onLoginClick: () -> Unit, + onOpenVerifyEmail: () -> Unit, + onOpenResetPassword: () -> Unit, ) { Column( modifier = Modifier @@ -86,14 +88,19 @@ fun LoginScreen( style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 12.dp), ) - } else { - TextButton( - onClick = {}, - enabled = false, - modifier = Modifier.padding(top = 8.dp), - ) { - Text(text = "Use your existing backend account") - } + } + TextButton( + onClick = onOpenVerifyEmail, + enabled = !state.isLoading, + modifier = Modifier.padding(top = 8.dp), + ) { + Text(text = "Verify email by token") + } + TextButton( + onClick = onOpenResetPassword, + enabled = !state.isLoading, + ) { + Text(text = "Forgot password") } } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/reset/ResetPasswordScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/reset/ResetPasswordScreen.kt new file mode 100644 index 0000000..4aac86e --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/reset/ResetPasswordScreen.kt @@ -0,0 +1,87 @@ +package ru.daemonlord.messenger.ui.auth.reset + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +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.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ru.daemonlord.messenger.ui.account.AccountViewModel + +@Composable +fun ResetPasswordRoute( + token: String?, + onBackToLogin: () -> Unit, + viewModel: AccountViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("Password reset", style = MaterialTheme.typography.headlineSmall) + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { viewModel.requestPasswordReset(email) }, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isSaving && email.isNotBlank(), + ) { + Text("Send reset link") + } + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("New password") }, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { + if (!token.isNullOrBlank()) { + viewModel.resetPassword(token = token, password = password) + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isSaving && !token.isNullOrBlank() && password.length >= 8, + ) { + Text("Reset with token") + } + if (state.isSaving) { + CircularProgressIndicator() + } + if (!state.message.isNullOrBlank()) { + Text(state.message!!, color = MaterialTheme.colorScheme.primary) + } + if (!state.errorMessage.isNullOrBlank()) { + Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error) + } + Button(onClick = onBackToLogin, modifier = Modifier.fillMaxWidth()) { + Text("Back to login") + } + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/verify/VerifyEmailScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/verify/VerifyEmailScreen.kt new file mode 100644 index 0000000..06fe5f5 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/verify/VerifyEmailScreen.kt @@ -0,0 +1,77 @@ +package ru.daemonlord.messenger.ui.auth.verify + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ru.daemonlord.messenger.ui.account.AccountViewModel + +@Composable +fun VerifyEmailRoute( + token: String?, + onBackToLogin: () -> Unit, + viewModel: AccountViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + var editableToken by remember(token) { mutableStateOf(token.orEmpty()) } + + LaunchedEffect(token) { + if (!token.isNullOrBlank()) { + viewModel.verifyEmail(token) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("Verify email", style = MaterialTheme.typography.headlineSmall) + OutlinedTextField( + value = editableToken, + onValueChange = { editableToken = it }, + label = { Text("Verification token") }, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { viewModel.verifyEmail(editableToken) }, + enabled = !state.isSaving && editableToken.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text("Verify") + } + if (state.isSaving) { + CircularProgressIndicator() + } + if (!state.message.isNullOrBlank()) { + Text(state.message!!, color = MaterialTheme.colorScheme.primary) + } + if (!state.errorMessage.isNullOrBlank()) { + Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error) + } + Button(onClick = onBackToLogin, modifier = Modifier.fillMaxWidth()) { + Text("Back to login") + } + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt index adbe2d5..798f5e7 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt @@ -31,6 +31,8 @@ import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController import ru.daemonlord.messenger.ui.auth.AuthViewModel import ru.daemonlord.messenger.ui.auth.LoginScreen +import ru.daemonlord.messenger.ui.auth.reset.ResetPasswordRoute +import ru.daemonlord.messenger.ui.auth.verify.VerifyEmailRoute import ru.daemonlord.messenger.ui.chat.ChatRoute import ru.daemonlord.messenger.ui.chats.ChatListRoute import ru.daemonlord.messenger.ui.profile.ProfileRoute @@ -39,6 +41,8 @@ import ru.daemonlord.messenger.ui.settings.SettingsRoute private object Routes { const val AuthGraph = "auth_graph" const val Login = "login" + const val VerifyEmail = "verify_email" + const val ResetPassword = "reset_password" const val Chats = "chats" const val Settings = "settings" const val Profile = "profile" @@ -51,6 +55,10 @@ fun MessengerNavHost( viewModel: AuthViewModel = hiltViewModel(), inviteToken: String? = null, onInviteTokenConsumed: () -> Unit = {}, + verifyEmailToken: String? = null, + onVerifyEmailTokenConsumed: () -> Unit = {}, + resetPasswordToken: String? = null, + onResetPasswordTokenConsumed: () -> Unit = {}, notificationChatId: Long? = null, notificationMessageId: Long? = null, onNotificationConsumed: () -> Unit = {}, @@ -72,10 +80,26 @@ fun MessengerNavHost( } } - LaunchedEffect(uiState.isCheckingSession, uiState.isAuthenticated, notificationChatId, notificationMessageId) { + LaunchedEffect(uiState.isCheckingSession, uiState.isAuthenticated, verifyEmailToken, resetPasswordToken, notificationChatId, notificationMessageId) { if (uiState.isCheckingSession) { return@LaunchedEffect } + if (!uiState.isAuthenticated && !verifyEmailToken.isNullOrBlank()) { + navController.navigate("${Routes.VerifyEmail}?token=$verifyEmailToken") { + popUpTo(navController.graph.findStartDestination().id) { inclusive = true } + launchSingleTop = true + } + onVerifyEmailTokenConsumed() + return@LaunchedEffect + } + if (!uiState.isAuthenticated && !resetPasswordToken.isNullOrBlank()) { + navController.navigate("${Routes.ResetPassword}?token=$resetPasswordToken") { + popUpTo(navController.graph.findStartDestination().id) { inclusive = true } + launchSingleTop = true + } + onResetPasswordTokenConsumed() + return@LaunchedEffect + } if (uiState.isAuthenticated) { if (notificationChatId != null) { navController.navigate("${Routes.Chat}/$notificationChatId") { @@ -120,11 +144,45 @@ fun MessengerNavHost( onEmailChanged = viewModel::onEmailChanged, onPasswordChanged = viewModel::onPasswordChanged, onLoginClick = viewModel::login, + onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) }, + onOpenResetPassword = { navController.navigate(Routes.ResetPassword) }, ) } } } + composable( + route = "${Routes.VerifyEmail}?token={token}", + arguments = listOf( + navArgument("token") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ), + ) { entry -> + VerifyEmailRoute( + token = entry.arguments?.getString("token"), + onBackToLogin = { navController.navigate(Routes.Login) }, + ) + } + + composable( + route = "${Routes.ResetPassword}?token={token}", + arguments = listOf( + navArgument("token") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ), + ) { entry -> + ResetPasswordRoute( + token = entry.arguments?.getString("token"), + onBackToLogin = { navController.navigate(Routes.Login) }, + ) + } + composable(route = Routes.Chats) { ChatListRoute( inviteToken = inviteToken,