android: switch invite join to app-link deep links
Some checks failed
CI / test (push) Failing after 2m9s

This commit is contained in:
Codex
2026-03-09 14:24:22 +03:00
parent e2a87ffb2e
commit 5c3535ef8f
8 changed files with 75 additions and 36 deletions

View File

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

View File

@@ -19,6 +19,15 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="chat.daemonlord.ru"
android:pathPrefix="/join"
android:scheme="https" />
</intent-filter>
</activity>
</application>

View File

@@ -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<String?>(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
}

View File

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

View File

@@ -12,7 +12,6 @@ data class ChatListUiState(
val chats: List<ChatItem> = emptyList(),
val archivedChatsCount: Int = 0,
val archivedUnreadCount: Int = 0,
val inviteTokenInput: String = "",
val isJoiningInvite: Boolean = false,
val pendingOpenChatId: Long? = null,
)

View File

@@ -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<ChatListUiState> = _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,
)
}

View File

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

View File

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