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 ### Step 39 - Android scope / remove calls UI
- Removed chat top-bar `Call` action from Android `ChatScreen`. - Removed chat top-bar `Call` action from Android `ChatScreen`.
- Updated Android UI checklist wording to reflect chat header without calls support. - 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" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </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> </activity>
</application> </application>

View File

@@ -1,5 +1,6 @@
package ru.daemonlord.messenger package ru.daemonlord.messenger
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@@ -7,6 +8,9 @@ import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable 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.ui.Modifier
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@@ -14,20 +18,52 @@ import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var pendingInviteToken by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
pendingInviteToken = intent.extractInviteToken()
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
MaterialTheme { MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) { 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 @Composable
private fun AppRoot() { private fun AppRoot(
MessengerNavHost() 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 @Composable
fun ChatListRoute( fun ChatListRoute(
onOpenChat: (Long) -> Unit, onOpenChat: (Long) -> Unit,
inviteToken: String?,
onInviteTokenConsumed: () -> Unit,
viewModel: ChatListViewModel = hiltViewModel(), viewModel: ChatListViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() 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) { LaunchedEffect(state.pendingOpenChatId) {
val chatId = state.pendingOpenChatId ?: return@LaunchedEffect val chatId = state.pendingOpenChatId ?: return@LaunchedEffect
onOpenChat(chatId) onOpenChat(chatId)
@@ -53,8 +61,6 @@ fun ChatListRoute(
onTabSelected = viewModel::onTabSelected, onTabSelected = viewModel::onTabSelected,
onFilterSelected = viewModel::onFilterSelected, onFilterSelected = viewModel::onFilterSelected,
onSearchChanged = viewModel::onSearchChanged, onSearchChanged = viewModel::onSearchChanged,
onInviteTokenChanged = viewModel::onInviteTokenChanged,
onJoinByInvite = viewModel::onJoinByInvite,
onRefresh = viewModel::onPullToRefresh, onRefresh = viewModel::onPullToRefresh,
onOpenChat = onOpenChat, onOpenChat = onOpenChat,
) )
@@ -67,8 +73,6 @@ fun ChatListScreen(
onTabSelected: (ChatTab) -> Unit, onTabSelected: (ChatTab) -> Unit,
onFilterSelected: (ChatListFilter) -> Unit, onFilterSelected: (ChatListFilter) -> Unit,
onSearchChanged: (String) -> Unit, onSearchChanged: (String) -> Unit,
onInviteTokenChanged: (String) -> Unit,
onJoinByInvite: () -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onOpenChat: (Long) -> 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( PullToRefreshBox(
isRefreshing = state.isRefreshing, isRefreshing = state.isRefreshing,
onRefresh = onRefresh, onRefresh = onRefresh,

View File

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

View File

@@ -32,6 +32,7 @@ class ChatListViewModel @Inject constructor(
private val selectedTab = MutableStateFlow(ChatTab.ALL) private val selectedTab = MutableStateFlow(ChatTab.ALL)
private val selectedFilter = MutableStateFlow(ChatListFilter.ALL) private val selectedFilter = MutableStateFlow(ChatListFilter.ALL)
private val searchQuery = MutableStateFlow("") private val searchQuery = MutableStateFlow("")
private var lastHandledInviteToken: String? = null
private val _uiState = MutableStateFlow(ChatListUiState()) private val _uiState = MutableStateFlow(ChatListUiState())
val uiState: StateFlow<ChatListUiState> = _uiState.asStateFlow() val uiState: StateFlow<ChatListUiState> = _uiState.asStateFlow()
@@ -62,12 +63,15 @@ class ChatListViewModel @Inject constructor(
refreshCurrentTab(forceRefresh = true) refreshCurrentTab(forceRefresh = true)
} }
fun onInviteTokenChanged(value: String) { fun onInviteTokenFromLink(token: String) {
_uiState.update { it.copy(inviteTokenInput = value) } val normalized = token.trim()
if (normalized.isBlank()) return
if (lastHandledInviteToken == normalized) return
lastHandledInviteToken = normalized
joinByInviteToken(normalized)
} }
fun onJoinByInvite() { private fun joinByInviteToken(token: String) {
val token = uiState.value.inviteTokenInput.trim()
if (token.isBlank()) return if (token.isBlank()) return
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isJoiningInvite = true, errorMessage = null) } _uiState.update { it.copy(isJoiningInvite = true, errorMessage = null) }
@@ -76,7 +80,6 @@ class ChatListViewModel @Inject constructor(
_uiState.update { _uiState.update {
it.copy( it.copy(
isJoiningInvite = false, isJoiningInvite = false,
inviteTokenInput = "",
pendingOpenChatId = result.data.id, pendingOpenChatId = result.data.id,
) )
} }

View File

@@ -38,6 +38,8 @@ private object Routes {
fun MessengerNavHost( fun MessengerNavHost(
navController: NavHostController = rememberNavController(), navController: NavHostController = rememberNavController(),
viewModel: AuthViewModel = hiltViewModel(), viewModel: AuthViewModel = hiltViewModel(),
inviteToken: String? = null,
onInviteTokenConsumed: () -> Unit = {},
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
@@ -86,6 +88,8 @@ fun MessengerNavHost(
composable(route = Routes.Chats) { composable(route = Routes.Chats) {
ChatListRoute( ChatListRoute(
inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed,
onOpenChat = { chatId -> onOpenChat = { chatId ->
navController.navigate("${Routes.Chat}/$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. 3. Realtime: new message appears without restart, reconnect after temporary network off/on.
4. Chat screen: send text, reply, edit, delete, forward, reaction toggle. 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. 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. 7. Session safety: expired access token refreshes transparently for API calls.
## Baseline targets (initial) ## Baseline targets (initial)