android: add verify and reset auth flows with deep link routing

This commit is contained in:
Codex
2026-03-09 16:05:26 +03:00
parent 91d712c702
commit 9ad8372d45
6 changed files with 278 additions and 9 deletions

View File

@@ -28,6 +28,14 @@
android:host="chat.daemonlord.ru"
android:pathPrefix="/join"
android:scheme="https" />
<data
android:host="chat.daemonlord.ru"
android:pathPrefix="/verify-email"
android:scheme="https" />
<data
android:host="chat.daemonlord.ru"
android:pathPrefix="/reset-password"
android:scheme="https" />
</intent-filter>
</activity>
<service

View File

@@ -20,12 +20,16 @@ import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private var pendingInviteToken by mutableStateOf<String?>(null)
private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
private var pendingNotificationChatId by mutableStateOf<Long?>(null)
private var pendingNotificationMessageId by mutableStateOf<Long?>(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()

View File

@@ -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,
onClick = onOpenVerifyEmail,
enabled = !state.isLoading,
modifier = Modifier.padding(top = 8.dp),
) {
Text(text = "Use your existing backend account")
Text(text = "Verify email by token")
}
TextButton(
onClick = onOpenResetPassword,
enabled = !state.isLoading,
) {
Text(text = "Forgot password")
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -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,