android: switch invite join to app-link deep links
Some checks failed
CI / test (push) Failing after 2m9s
Some checks failed
CI / test (push) Failing after 2m9s
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user