android: add verify and reset auth flows with deep link routing
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user