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)