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
|
### 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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user