From 5c3535ef8f610989f0d28478394ed87de7418483 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 14:24:22 +0300 Subject: [PATCH] android: switch invite join to app-link deep links --- android/CHANGELOG.md | 6 +++ android/app/src/main/AndroidManifest.xml | 9 ++++ .../ru/daemonlord/messenger/MainActivity.kt | 42 +++++++++++++++++-- .../messenger/ui/chats/ChatListScreen.kt | 34 ++++----------- .../messenger/ui/chats/ChatListUiState.kt | 1 - .../messenger/ui/chats/ChatListViewModel.kt | 13 +++--- .../messenger/ui/navigation/AppNavGraph.kt | 4 ++ docs/android-smoke.md | 2 +- 8 files changed, 75 insertions(+), 36 deletions(-) diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 61609ba..f79a189 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -241,3 +241,9 @@ ### Step 39 - Android scope / remove calls UI - Removed chat top-bar `Call` action from Android `ChatScreen`. - Updated Android UI checklist wording to reflect chat header without calls support. + +### Step 40 - Invite deep link flow (app links) +- Added Android App Links intent filter for `https://chat.daemonlord.ru/join...`. +- Added invite token extraction from incoming intents (`query token` and `/join/{token}` path formats). +- Wired deep link token into `MessengerNavHost -> ChatListRoute -> ChatListViewModel` auto-join flow. +- Removed manual `Invite token` input row from chat list screen. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0ae81aa..7f6906b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -19,6 +19,15 @@ + + + + + + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt b/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt index 538fccc..10cbda3 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt @@ -1,5 +1,6 @@ package ru.daemonlord.messenger +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.enableEdgeToEdge @@ -7,6 +8,9 @@ import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.foundation.layout.fillMaxSize import dagger.hilt.android.AndroidEntryPoint @@ -14,20 +18,52 @@ import ru.daemonlord.messenger.ui.navigation.MessengerNavHost @AndroidEntryPoint class MainActivity : ComponentActivity() { + private var pendingInviteToken by mutableStateOf(null) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + pendingInviteToken = intent.extractInviteToken() enableEdgeToEdge() setContent { MaterialTheme { Surface(modifier = Modifier.fillMaxSize()) { - AppRoot() + AppRoot( + inviteToken = pendingInviteToken, + onInviteTokenConsumed = { pendingInviteToken = null }, + ) } } } } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + pendingInviteToken = intent.extractInviteToken() ?: pendingInviteToken + } } @Composable -private fun AppRoot() { - MessengerNavHost() +private fun AppRoot( + inviteToken: String?, + onInviteTokenConsumed: () -> Unit, +) { + MessengerNavHost( + inviteToken = inviteToken, + onInviteTokenConsumed = onInviteTokenConsumed, + ) +} + +private fun Intent?.extractInviteToken(): String? { + val uri = this?.data ?: return null + val queryToken = uri.getQueryParameter("token")?.trim().orEmpty() + if (queryToken.isNotBlank()) return queryToken + + val segments = uri.pathSegments + val joinIndex = segments.indexOf("join") + if (joinIndex >= 0 && joinIndex + 1 < segments.size) { + val token = segments[joinIndex + 1].trim() + if (token.isNotBlank()) return token + } + return null } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt index 05b5e1f..4cd12aa 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt @@ -40,9 +40,17 @@ import ru.daemonlord.messenger.domain.chat.model.ChatItem @Composable fun ChatListRoute( onOpenChat: (Long) -> Unit, + inviteToken: String?, + onInviteTokenConsumed: () -> Unit, viewModel: ChatListViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(inviteToken) { + val token = inviteToken?.trim().orEmpty() + if (token.isBlank()) return@LaunchedEffect + viewModel.onInviteTokenFromLink(token) + onInviteTokenConsumed() + } LaunchedEffect(state.pendingOpenChatId) { val chatId = state.pendingOpenChatId ?: return@LaunchedEffect onOpenChat(chatId) @@ -53,8 +61,6 @@ fun ChatListRoute( onTabSelected = viewModel::onTabSelected, onFilterSelected = viewModel::onFilterSelected, onSearchChanged = viewModel::onSearchChanged, - onInviteTokenChanged = viewModel::onInviteTokenChanged, - onJoinByInvite = viewModel::onJoinByInvite, onRefresh = viewModel::onPullToRefresh, onOpenChat = onOpenChat, ) @@ -67,8 +73,6 @@ fun ChatListScreen( onTabSelected: (ChatTab) -> Unit, onFilterSelected: (ChatListFilter) -> Unit, onSearchChanged: (String) -> Unit, - onInviteTokenChanged: (String) -> Unit, - onJoinByInvite: () -> Unit, onRefresh: () -> Unit, onOpenChat: (Long) -> Unit, ) { @@ -130,28 +134,6 @@ fun ChatListScreen( ) } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = state.inviteTokenInput, - onValueChange = onInviteTokenChanged, - label = { Text("Invite token") }, - singleLine = true, - modifier = Modifier.weight(1f), - ) - Button( - onClick = onJoinByInvite, - enabled = !state.isJoiningInvite && state.inviteTokenInput.isNotBlank(), - ) { - Text(if (state.isJoiningInvite) "..." else "Join") - } - } - PullToRefreshBox( isRefreshing = state.isRefreshing, onRefresh = onRefresh, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt index 6c6bce2..9ae02d3 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt @@ -12,7 +12,6 @@ data class ChatListUiState( val chats: List = emptyList(), val archivedChatsCount: Int = 0, val archivedUnreadCount: Int = 0, - val inviteTokenInput: String = "", val isJoiningInvite: Boolean = false, val pendingOpenChatId: Long? = null, ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt index e93fe9b..502b299 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt @@ -32,6 +32,7 @@ class ChatListViewModel @Inject constructor( private val selectedTab = MutableStateFlow(ChatTab.ALL) private val selectedFilter = MutableStateFlow(ChatListFilter.ALL) private val searchQuery = MutableStateFlow("") + private var lastHandledInviteToken: String? = null private val _uiState = MutableStateFlow(ChatListUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -62,12 +63,15 @@ class ChatListViewModel @Inject constructor( refreshCurrentTab(forceRefresh = true) } - fun onInviteTokenChanged(value: String) { - _uiState.update { it.copy(inviteTokenInput = value) } + fun onInviteTokenFromLink(token: String) { + val normalized = token.trim() + if (normalized.isBlank()) return + if (lastHandledInviteToken == normalized) return + lastHandledInviteToken = normalized + joinByInviteToken(normalized) } - fun onJoinByInvite() { - val token = uiState.value.inviteTokenInput.trim() + private fun joinByInviteToken(token: String) { if (token.isBlank()) return viewModelScope.launch { _uiState.update { it.copy(isJoiningInvite = true, errorMessage = null) } @@ -76,7 +80,6 @@ class ChatListViewModel @Inject constructor( _uiState.update { it.copy( isJoiningInvite = false, - inviteTokenInput = "", pendingOpenChatId = result.data.id, ) } 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 3b00bd6..e4172e9 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 @@ -38,6 +38,8 @@ private object Routes { fun MessengerNavHost( navController: NavHostController = rememberNavController(), viewModel: AuthViewModel = hiltViewModel(), + inviteToken: String? = null, + onInviteTokenConsumed: () -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsState() @@ -86,6 +88,8 @@ fun MessengerNavHost( composable(route = Routes.Chats) { ChatListRoute( + inviteToken = inviteToken, + onInviteTokenConsumed = onInviteTokenConsumed, onOpenChat = { chatId -> navController.navigate("${Routes.Chat}/$chatId") } diff --git a/docs/android-smoke.md b/docs/android-smoke.md index 01a0370..b8576fc 100644 --- a/docs/android-smoke.md +++ b/docs/android-smoke.md @@ -6,7 +6,7 @@ 3. Realtime: new message appears without restart, reconnect after temporary network off/on. 4. Chat screen: send text, reply, edit, delete, forward, reaction toggle. 5. Media: send image/file/audio, image opens in viewer, audio play/pause works. -6. Invite flow: join-by-invite token from Chat List opens joined chat. +6. Invite flow: open invite deep link (`chat.daemonlord.ru/join...`) and verify joined chat auto-opens. 7. Session safety: expired access token refreshes transparently for API calls. ## Baseline targets (initial)