diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 370f4b819..37bab25c8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -230,6 +230,7 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.paging.compose) implementation(libs.coil.network.okhttp) implementation(libs.coil.svg) implementation(libs.androidx.hilt.lifecycle.viewmodel.compose) diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt index 6801b46ea..25577600c 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt @@ -17,6 +17,7 @@ package com.geeksville.mesh.ui.contact +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -36,6 +37,7 @@ import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.rounded.QrCode2 import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingActionButton @@ -63,6 +65,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey import com.geeksville.mesh.model.Contact import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest @@ -70,6 +76,7 @@ import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.database.entity.ContactSettings import org.meshtastic.core.model.util.formatMuteRemainingTime +import org.meshtastic.core.model.util.getChannel import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.cancel import org.meshtastic.core.strings.close_selection @@ -116,7 +123,27 @@ fun ContactsScreen( val isSelectionModeActive by remember { derivedStateOf { selectedContactKeys.isNotEmpty() } } // State for contacts list - val contacts by viewModel.contactList.collectAsStateWithLifecycle() + val pagedContacts = viewModel.contactListPaged.collectAsLazyPagingItems() + + // Create channel placeholders (always show broadcast contacts, even when empty) + val channels by viewModel.channels.collectAsStateWithLifecycle() + val channelPlaceholders = + remember(channels.settingsList.size) { + (0 until channels.settingsList.size).map { ch -> + Contact( + contactKey = "$ch^all", + shortName = "$ch", + longName = channels.getChannel(ch)?.name ?: "Channel $ch", + lastMessageTime = "", + lastMessageText = "", + unreadCount = 0, + messageCount = 0, + isMuted = false, + isUnmessageable = false, + nodeColors = null, + ) + } + } val contactsListState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() @@ -131,7 +158,11 @@ fun ContactsScreen( // Derived state for selected contacts and count val selectedContacts = - remember(contacts, selectedContactKeys) { contacts.filter { it.contactKey in selectedContactKeys } } + remember(pagedContacts.itemCount, selectedContactKeys) { + (0 until pagedContacts.itemCount) + .mapNotNull { pagedContacts[it] } + .filter { it.contactKey in selectedContactKeys } + } val selectedCount = remember(selectedContacts) { selectedContacts.sumOf { it.messageCount } } val isAllMuted = remember(selectedContacts) { selectedContacts.all { it.isMuted } } @@ -209,15 +240,17 @@ fun ContactsScreen( onDeleteSelected = { showDeleteDialog = true }, onSelectAll = { selectedContactKeys.clear() - selectedContactKeys.addAll(contacts.map { it.contactKey }) + selectedContactKeys.addAll( + (0 until pagedContacts.itemCount).mapNotNull { pagedContacts[it]?.contactKey }, + ) }, isAllMuted = isAllMuted, // Pass the derived state ) } - val channels by viewModel.channels.collectAsStateWithLifecycle() - ContactListView( - contacts = contacts, + ContactListViewPaged( + contacts = pagedContacts, + channelPlaceholders = channelPlaceholders, selectedList = selectedContactKeys, onClick = onContactClick, onLongClick = onContactLongClick, @@ -435,6 +468,14 @@ fun SelectionToolbar( ) } +/** + * Non-paginated contact list view. + * + * NOTE: This is kept for ShareScreen which displays a simple contact picker. The main ContactsScreen uses + * [ContactListViewPaged] for better performance. + * + * @see ContactListViewPaged for the paginated version + */ @Composable fun ContactListView( contacts: List, @@ -464,3 +505,96 @@ fun ContactListView( } } } + +@Composable +fun ContactListViewPaged( + contacts: LazyPagingItems, + channelPlaceholders: List, + selectedList: List, + onClick: (Contact) -> Unit, + onLongClick: (Contact) -> Unit, + onNodeChipClick: (Contact) -> Unit, + listState: LazyListState, + modifier: Modifier = Modifier, + channels: AppOnlyProtos.ChannelSet? = null, +) { + val haptics = LocalHapticFeedback.current + + // Handle initial loading state + if (contacts.loadState.refresh is LoadState.Loading && contacts.itemCount == 0) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + return + } + + val visiblePlaceholders = rememberVisiblePlaceholders(contacts, channelPlaceholders) + + LazyColumn(modifier = modifier.fillMaxSize(), state = listState) { + // Show channel placeholders first + items( + count = visiblePlaceholders.size, + key = { index -> "placeholder_${visiblePlaceholders[index].contactKey}" }, + ) { index -> + val placeholder = visiblePlaceholders[index] + val selected by remember { derivedStateOf { selectedList.contains(placeholder.contactKey) } } + + ContactItem( + contact = placeholder, + selected = selected, + onClick = { onClick(placeholder) }, + onLongClick = { + onLongClick(placeholder) + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + }, + channels = channels, + onNodeChipClick = { onNodeChipClick(placeholder) }, + ) + } + + // Show paged contacts + items(count = contacts.itemCount, key = contacts.itemKey { it.contactKey }) { index -> + val contact = contacts[index] + if (contact != null) { + val selected by remember { derivedStateOf { selectedList.contains(contact.contactKey) } } + + ContactItem( + contact = contact, + selected = selected, + onClick = { onClick(contact) }, + onLongClick = { + onLongClick(contact) + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + }, + channels = channels, + onNodeChipClick = { onNodeChipClick(contact) }, + ) + } + } + + // Loading indicator when loading more contacts + contacts.apply { + when { + loadState.append is LoadState.Loading -> { + item(key = "append_loading") { + Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + } + } + } + } +} + +@Composable +private fun rememberVisiblePlaceholders( + contacts: LazyPagingItems, + channelPlaceholders: List, +): List { + val contactKeys by + remember(contacts.itemCount) { + derivedStateOf { (0 until contacts.itemCount).mapNotNull { contacts[it]?.contactKey }.toSet() } + } + return remember(channelPlaceholders, contactKeys) { + channelPlaceholders.filter { placeholder -> !contactKeys.contains(placeholder.contactKey) } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt index 4165027a3..10ff696cd 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt @@ -19,11 +19,18 @@ package com.geeksville.mesh.ui.contact import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map import com.geeksville.mesh.model.Contact import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.jetbrains.compose.resources.getString import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.data.repository.PacketRepository @@ -38,7 +45,7 @@ import org.meshtastic.core.strings.channel_name import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.channelSet import javax.inject.Inject -import kotlin.collections.map +import kotlin.collections.map as collectionsMap @HiltViewModel class ContactsViewModel @@ -55,6 +62,14 @@ constructor( val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = channelSet {}) + /** + * Non-paginated contact list. + * + * NOTE: This is kept for ShareScreen which needs a simple, non-paginated list of contacts. The main ContactsScreen + * uses [contactListPaged] instead for better performance with large contact lists. + * + * @see contactListPaged for the paginated version used in ContactsScreen + */ val contactList = combine( nodeRepository.myNodeInfo, @@ -71,7 +86,7 @@ constructor( contactKey to Packet(0L, myNodeNum, 1, contactKey, 0L, true, data) } - (contacts + (placeholder - contacts.keys)).values.map { packet -> + (contacts + (placeholder - contacts.keys)).values.collectionsMap { packet -> val data = packet.data val contactKey = packet.contact_key @@ -112,6 +127,58 @@ constructor( } .stateInWhileSubscribed(initialValue = emptyList()) + val contactListPaged: Flow> = + combine(nodeRepository.myNodeInfo, channels, packetRepository.getContactSettings()) { + myNodeInfo, + channelSet, + settings, + -> + Triple(myNodeInfo?.myNodeNum, channelSet, settings) + } + .flatMapLatest { (myNodeNum, channelSet, settings) -> + packetRepository.getContactsPaged().map { pagingData -> + pagingData.map { packet -> + val data = packet.data + val contactKey = packet.contact_key + + // Determine if this is my message (originated on this device) + val fromLocal = data.from == DataPacket.ID_LOCAL + val toBroadcast = data.to == DataPacket.ID_BROADCAST + + // grab usernames from NodeInfo + val user = getUser(if (fromLocal) data.to else data.from) + val node = getNode(if (fromLocal) data.to else data.from) + + val shortName = user.shortName + val longName = + if (toBroadcast) { + channelSet.getChannel(data.channel)?.name ?: getString(Res.string.channel_name) + } else { + user.longName + } + + Contact( + contactKey = contactKey, + shortName = if (toBroadcast) "${data.channel}" else shortName, + longName = longName, + lastMessageTime = getShortDate(data.time), + lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}", + unreadCount = runBlocking(Dispatchers.IO) { packetRepository.getUnreadCount(contactKey) }, + messageCount = runBlocking(Dispatchers.IO) { packetRepository.getMessageCount(contactKey) }, + isMuted = settings[contactKey]?.isMuted == true, + isUnmessageable = user.isUnmessagable, + nodeColors = + if (!toBroadcast) { + node.colors + } else { + null + }, + ) + } + } + } + .cachedIn(viewModelScope) + fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) fun deleteContacts(contacts: List) = diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 1c0b72496..99331456b 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -36,8 +36,10 @@ dependencies { // Needed because core:data references MeshtasticDatabase (supertype RoomDatabase) implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.paging) implementation(libs.androidx.core.location.altitude) + implementation(libs.androidx.paging.common) implementation(libs.kotlinx.serialization.json) implementation(libs.timber) } diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt index 0e56004bb..ff65e44ac 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt @@ -17,14 +17,20 @@ package org.meshtastic.core.data.repository +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.map import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.withContext import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.database.entity.ContactSettings import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.database.entity.ReactionEntity +import org.meshtastic.core.database.model.Message import org.meshtastic.core.database.model.Node import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DataPacket @@ -45,12 +51,24 @@ constructor( fun getContacts(): Flow> = dbManager.currentDb.flatMapLatest { db -> db.packetDao().getContactKeys() } + fun getContactsPaged(): Flow> = Pager( + config = PagingConfig(pageSize = 30, enablePlaceholders = false, initialLoadSize = 30), + pagingSourceFactory = { dbManager.currentDb.value.packetDao().getContactKeysPaged() }, + ) + .flow + suspend fun getMessageCount(contact: String): Int = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getMessageCount(contact) } suspend fun getUnreadCount(contact: String): Int = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getUnreadCount(contact) } + fun getFirstUnreadMessageUuid(contact: String): Flow = + dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFirstUnreadMessageUuid(contact) } + + fun hasUnreadMessages(contact: String): Flow = + dbManager.currentDb.flatMapLatest { db -> db.packetDao().hasUnreadMessages(contact) } + fun getUnreadCountTotal(): Flow = dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountTotal() } @@ -92,6 +110,22 @@ constructor( } } + fun getMessagesFromPaged(contact: String, getNode: suspend (String?) -> Node): Flow> = Pager( + config = PagingConfig(pageSize = 50, enablePlaceholders = false, initialLoadSize = 50), + pagingSourceFactory = { dbManager.currentDb.value.packetDao().getMessagesFromPaged(contact) }, + ) + .flow + .map { pagingData -> + pagingData.map { packet -> + val message = packet.toMessage(getNode) + message.replyId + .takeIf { it != null && it != 0 } + ?.let { getPacketByPacketId(it) } + ?.toMessage(getNode) + ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message + } + } + suspend fun updateMessageStatus(d: DataPacket, m: MessageStatus) = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateMessageStatus(d, m) } diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 1f84e8740..a324c2cae 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { implementation(projects.core.proto) implementation(projects.core.strings) + implementation(libs.androidx.room.paging) implementation(libs.kotlinx.serialization.json) implementation(libs.timber) diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt index 9ee0aa928..6cc03197b 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.database.dao +import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.MapColumn import androidx.room.Query @@ -62,6 +63,23 @@ interface PacketDao { >, > + @Query( + """ + SELECT p.* FROM packet p + INNER JOIN ( + SELECT contact_key, MAX(received_time) as max_time + FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND port_num = 1 + GROUP BY contact_key + ) latest ON p.contact_key = latest.contact_key AND p.received_time = latest.max_time + WHERE (p.myNodeNum = 0 OR p.myNodeNum = (SELECT myNodeNum FROM my_node)) + AND p.port_num = 1 + ORDER BY p.received_time DESC + """, + ) + fun getContactKeysPaged(): PagingSource + @Query( """ SELECT COUNT(*) FROM packet @@ -80,6 +98,26 @@ interface PacketDao { ) suspend fun getUnreadCount(contact: String): Int + @Query( + """ + SELECT uuid FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND port_num = 1 AND contact_key = :contact AND read = 0 + ORDER BY received_time ASC + LIMIT 1 + """, + ) + fun getFirstUnreadMessageUuid(contact: String): Flow + + @Query( + """ + SELECT COUNT(*) > 0 FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND port_num = 1 AND contact_key = :contact AND read = 0 + """, + ) + fun hasUnreadMessages(contact: String): Flow + @Query( """ SELECT COUNT(*) FROM packet @@ -112,6 +150,17 @@ interface PacketDao { ) fun getMessagesFrom(contact: String): Flow> + @Transaction + @Query( + """ + SELECT * FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND port_num = 1 AND contact_key = :contact + ORDER BY received_time DESC + """, + ) + fun getMessagesFromPaged(contact: String): PagingSource + @Query( """ SELECT * FROM packet diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts index 536988256..9c5c4a8ac 100644 --- a/feature/messaging/build.gradle.kts +++ b/feature/messaging/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { implementation(libs.androidx.compose.ui.text) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.hilt.lifecycle.viewmodel.compose) + implementation(libs.androidx.paging.compose) implementation(libs.timber) debugImplementation(libs.androidx.compose.ui.test.manifest) diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt index 0504feac2..2bff55e33 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt @@ -93,6 +93,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.collectAsLazyPagingItems import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.jetbrains.compose.resources.pluralStringResource @@ -166,7 +167,7 @@ fun MessageScreen( val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() val channels by viewModel.channels.collectAsStateWithLifecycle() val quickChatActions by viewModel.quickChatActions.collectAsStateWithLifecycle(initialValue = emptyList()) - val messages by viewModel.getMessagesFrom(contactKey).collectAsStateWithLifecycle(initialValue = emptyList()) + val pagedMessages = viewModel.getMessagesFromPaged(contactKey).collectAsLazyPagingItems() val contactSettings by viewModel.contactSettings.collectAsStateWithLifecycle(initialValue = emptyMap()) // UI State managed within this Composable @@ -211,55 +212,35 @@ fun MessageScreen( derivedStateOf { contactSettings[contactKey]?.lastReadMessageTimestamp } } + // Track unread messages using lightweight metadata queries + val hasUnreadMessages by viewModel.hasUnreadMessages(contactKey).collectAsStateWithLifecycle(initialValue = false) + val firstUnreadMessageUuid by + viewModel.getFirstUnreadMessageUuid(contactKey).collectAsStateWithLifecycle(initialValue = null) + var hasPerformedInitialScroll by rememberSaveable(contactKey) { mutableStateOf(false) } - val hasUnreadMessages = messages.any { !it.read && !it.fromLocal } - - val earliestUnreadIndex by - remember(messages, lastReadMessageTimestamp) { - derivedStateOf { findEarliestUnreadIndex(messages, lastReadMessageTimestamp) } - } - - val initialUnreadUuidState = rememberSaveable(contactKey) { mutableStateOf(null) } - - LaunchedEffect(messages, earliestUnreadIndex, hasUnreadMessages) { - if (!hasUnreadMessages) { - initialUnreadUuidState.value = null - return@LaunchedEffect - } - val currentUuid = initialUnreadUuidState.value - val fallbackUuid = earliestUnreadIndex?.let { idx -> messages.getOrNull(idx)?.uuid } - if (currentUuid != null) { - val uuidStillPresent = messages.any { it.uuid == currentUuid } - if (!uuidStillPresent) { - initialUnreadUuidState.value = fallbackUuid - } - } else { - initialUnreadUuidState.value = fallbackUuid - } - } - - val initialUnreadMessageUuid = initialUnreadUuidState.value - - val initialUnreadIndex by - remember(messages, initialUnreadMessageUuid) { + // Find the index of the first unread message in the paged list + val firstUnreadIndex by + remember(pagedMessages.itemCount, firstUnreadMessageUuid) { derivedStateOf { - initialUnreadMessageUuid?.let { uuid -> messages.indexOfFirst { it.uuid == uuid } }?.takeIf { it >= 0 } + firstUnreadMessageUuid?.let { uuid -> + (0 until pagedMessages.itemCount).firstOrNull { index -> pagedMessages[index]?.uuid == uuid } + } } } - LaunchedEffect(messages, initialUnreadIndex, earliestUnreadIndex) { - if (!hasPerformedInitialScroll && messages.isNotEmpty()) { - val unreadStart = initialUnreadIndex ?: earliestUnreadIndex - val targetIndex = - when { - unreadStart == null -> 0 - unreadStart <= 0 -> 0 - else -> (unreadStart - (UnreadUiDefaults.VISIBLE_CONTEXT_COUNT - 1)).coerceAtLeast(0) - } - if (listState.firstVisibleItemIndex != targetIndex) { - listState.smartScrollToIndex(coroutineScope = coroutineScope, targetIndex = targetIndex) - } + // Scroll to first unread message on initial load + LaunchedEffect(hasPerformedInitialScroll, firstUnreadIndex, pagedMessages.itemCount) { + if (hasPerformedInitialScroll || pagedMessages.itemCount == 0) return@LaunchedEffect + + val shouldScrollToUnread = hasUnreadMessages && firstUnreadIndex != null + if (shouldScrollToUnread) { + val targetIndex = (firstUnreadIndex!! - (UnreadUiDefaults.VISIBLE_CONTEXT_COUNT - 1)).coerceAtLeast(0) + listState.smartScrollToIndex(coroutineScope = coroutineScope, targetIndex = targetIndex) + hasPerformedInitialScroll = true + } else if (!hasUnreadMessages) { + // If no unread messages, just scroll to bottom (most recent) + listState.scrollToItem(0) hasPerformedInitialScroll = true } } @@ -322,7 +303,8 @@ fun MessageScreen( when (action) { MessageMenuAction.ClipboardCopy -> { val copiedText = - messages + (0 until pagedMessages.itemCount) + .mapNotNull { pagedMessages[it] } .filter { it.uuid in selectedMessageIds.value } .joinToString("\n") { it.text } onEvent(MessageScreenEvent.CopyToClipboard(copiedText)) @@ -331,11 +313,14 @@ fun MessageScreen( MessageMenuAction.Delete -> showDeleteDialog = true MessageMenuAction.Dismiss -> selectedMessageIds.value = emptySet() MessageMenuAction.SelectAll -> { + // Note: Select All is disabled with pagination since we don't have + // access to the full message list. This would need to be reworked + // to select all currently loaded items instead. selectedMessageIds.value = - if (selectedMessageIds.value.size == messages.size) { + if (selectedMessageIds.value.size == pagedMessages.itemCount) { emptySet() } else { - messages.map { it.uuid }.toSet() + (0 until pagedMessages.itemCount).mapNotNull { pagedMessages[it]?.uuid }.toSet() } } } @@ -358,19 +343,18 @@ fun MessageScreen( ) { paddingValues -> Column(Modifier.padding(paddingValues)) { Box(modifier = Modifier.weight(1f)) { - MessageList( + MessageListPaged( modifier = Modifier.fillMaxSize(), listState = listState, state = - MessageListState( + MessageListPagedState( nodes = nodes, ourNode = ourNode, - messages = messages, + messages = pagedMessages, selectedIds = selectedMessageIds, - hasUnreadMessages = hasUnreadMessages, - initialUnreadMessageUuid = initialUnreadMessageUuid, - fallbackUnreadIndex = earliestUnreadIndex, contactKey = contactKey, + firstUnreadMessageUuid = firstUnreadMessageUuid, + hasUnreadMessages = hasUnreadMessages, ), handlers = MessageListHandlers( @@ -403,9 +387,13 @@ fun MessageScreen( ) } val originalMessage by - remember(replyingToPacketId, messages) { + remember(replyingToPacketId, pagedMessages.itemCount) { derivedStateOf { - replyingToPacketId?.let { messages.firstOrNull { it.packetId == replyingToPacketId } } + replyingToPacketId?.let { id -> + (0 until pagedMessages.itemCount).firstNotNullOfOrNull { index -> + pagedMessages[index]?.takeIf { it.packetId == id } + } + } } } ReplySnippet( diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt similarity index 60% rename from feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt rename to feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt index 524031efb..77af3ba1f 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt @@ -18,6 +18,7 @@ package org.meshtastic.feature.messaging import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -25,8 +26,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -38,6 +39,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -46,6 +48,9 @@ import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.itemKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest @@ -61,19 +66,6 @@ import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.new_messages_below import org.meshtastic.feature.messaging.component.MessageItem import org.meshtastic.feature.messaging.component.ReactionDialog -import timber.log.Timber -import kotlin.collections.buildList - -internal data class MessageListState( - val nodes: List, - val ourNode: Node?, - val messages: List, - val selectedIds: MutableState>, - val hasUnreadMessages: Boolean, - val initialUnreadMessageUuid: Long?, - val fallbackUnreadIndex: Int?, - val contactKey: String, -) internal data class MessageListHandlers( val onUnreadChanged: (Long, Long) -> Unit, @@ -84,6 +76,16 @@ internal data class MessageListHandlers( val onReply: (Message?) -> Unit, ) +internal data class MessageListPagedState( + val nodes: List, + val ourNode: Node?, + val messages: LazyPagingItems, + val selectedIds: MutableState>, + val contactKey: String, + val firstUnreadMessageUuid: Long? = null, + val hasUnreadMessages: Boolean = false, +) + private fun MutableState>.toggle(uuid: Long) { value = if (value.contains(uuid)) { @@ -94,25 +96,14 @@ private fun MutableState>.toggle(uuid: Long) { } @Composable -internal fun MessageList( +internal fun MessageListPaged( + state: MessageListPagedState, + handlers: MessageListHandlers, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), - state: MessageListState, - handlers: MessageListHandlers, ) { val haptics = LocalHapticFeedback.current val inSelectionMode by remember { derivedStateOf { state.selectedIds.value.isNotEmpty() } } - val unreadDividerIndex by - remember(state.messages, state.initialUnreadMessageUuid, state.fallbackUnreadIndex) { - derivedStateOf { - state.initialUnreadMessageUuid?.let { uuid -> - state.messages.indexOfFirst { it.uuid == uuid }.takeIf { it >= 0 } - } ?: state.fallbackUnreadIndex - } - } - val showUnreadDivider = state.hasUnreadMessages && unreadDividerIndex != null - AutoScrollToBottom(listState, state.messages, state.hasUnreadMessages) - UpdateUnreadCount(listState, state.messages, handlers.onUnreadChanged) var showStatusDialog by remember { mutableStateOf(null) } showStatusDialog?.let { message -> @@ -133,17 +124,19 @@ internal fun MessageList( showReactionDialog?.let { reactions -> ReactionDialog(reactions) { showReactionDialog = null } } val coroutineScope = rememberCoroutineScope() - val messageRows = - rememberMessageRows( - messages = state.messages, - showUnreadDivider = showUnreadDivider, - unreadDividerIndex = unreadDividerIndex, - initialUnreadMessageUuid = state.initialUnreadMessageUuid, - ) - MessageListContent( + // Track unread count based on scroll position + UpdateUnreadCountPaged(listState = listState, messages = state.messages, onUnreadChange = handlers.onUnreadChanged) + + // Auto-scroll to bottom when new messages arrive + AutoScrollToBottomPaged( + listState = listState, + messages = state.messages, + hasUnreadMessages = state.hasUnreadMessages, + ) + + MessageListPagedContent( listState = listState, - messageRows = messageRows, state = state, handlers = handlers, inSelectionMode = inSelectionMode, @@ -155,17 +148,10 @@ internal fun MessageList( ) } -private sealed interface MessageListRow { - data class ChatMessage(val index: Int, val message: Message) : MessageListRow - - data class UnreadDivider(val key: String) : MessageListRow -} - @Composable -private fun MessageListContent( +private fun MessageListPagedContent( listState: LazyListState, - messageRows: List, - state: MessageListState, + state: MessageListPagedState, handlers: MessageListHandlers, inSelectionMode: Boolean, coroutineScope: CoroutineScope, @@ -174,21 +160,23 @@ private fun MessageListContent( onShowReactions: (List) -> Unit, modifier: Modifier = Modifier, ) { - LazyColumn(modifier = modifier.fillMaxSize(), state = listState, reverseLayout = true) { - items( - items = messageRows, - key = { row -> - when (row) { - is MessageListRow.ChatMessage -> row.message.uuid - is MessageListRow.UnreadDivider -> row.key + // Calculate unread divider position + val unreadDividerIndex by + remember(state.messages.itemCount, state.firstUnreadMessageUuid) { + derivedStateOf { + state.firstUnreadMessageUuid?.let { uuid -> + (0 until state.messages.itemCount).firstOrNull { index -> state.messages[index]?.uuid == uuid } } - }, - ) { row -> - when (row) { - is MessageListRow.UnreadDivider -> UnreadMessagesDivider(modifier = Modifier.animateItem()) - is MessageListRow.ChatMessage -> - renderChatMessageRow( - row = row, + } + } + + Box(modifier = modifier.fillMaxSize()) { + LazyColumn(modifier = Modifier.fillMaxSize(), state = listState, reverseLayout = true) { + items(count = state.messages.itemCount, key = state.messages.itemKey { it.uuid }) { index -> + val message = state.messages[index] + if (message != null) { + renderPagedChatMessageRow( + message = message, state = state, handlers = handlers, inSelectionMode = inSelectionMode, @@ -198,15 +186,37 @@ private fun MessageListContent( onShowStatusDialog = onShowStatusDialog, onShowReactions = onShowReactions, ) + + // Show unread divider after the first unread message + if (state.hasUnreadMessages && unreadDividerIndex == index) { + UnreadMessagesDivider(modifier = Modifier.animateItem()) + } + } + } + + // Loading indicator at the end (top when reversed) when loading more items + state.messages.apply { + when { + loadState.append is LoadState.Loading -> { + item(key = "append_loading") { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } + } } } } } @Composable -private fun LazyItemScope.renderChatMessageRow( - row: MessageListRow.ChatMessage, - state: MessageListState, +private fun LazyItemScope.renderPagedChatMessageRow( + message: Message, + state: MessageListPagedState, handlers: MessageListHandlers, inSelectionMode: Boolean, coroutineScope: CoroutineScope, @@ -216,7 +226,6 @@ private fun LazyItemScope.renderChatMessageRow( onShowReactions: (List) -> Unit, ) { val ourNode = state.ourNode ?: return - val message = row.message val selected by remember(message.uuid, state.selectedIds.value) { derivedStateOf { state.selectedIds.value.contains(message.uuid) } @@ -245,8 +254,14 @@ private fun LazyItemScope.renderChatMessageRow( onShowReactions = { onShowReactions(message.emojis) }, onNavigateToOriginalMessage = { coroutineScope.launch { - val targetIndex = state.messages.indexOfFirst { it.packetId == message.replyId } - if (targetIndex != -1) { + // Note: With pagination, we can't guarantee the original message is loaded + // This is a limitation of pagination - we would need to implement + // a search/jump feature to load and scroll to specific messages + val targetIndex = + (0 until state.messages.itemCount).firstOrNull { index -> + state.messages[index]?.packetId == message.replyId + } + if (targetIndex != null) { listState.animateScrollToItem(index = targetIndex) } } @@ -255,7 +270,115 @@ private fun LazyItemScope.renderChatMessageRow( } @Composable -private fun MessageStatusDialog( +private fun AutoScrollToBottomPaged( + listState: LazyListState, + messages: LazyPagingItems, + hasUnreadMessages: Boolean, + itemThreshold: Int = 3, +) = with(listState) { + val shouldAutoScroll by + remember(hasUnreadMessages) { + derivedStateOf { + val isAtBottom = + firstVisibleItemIndex == 0 && + firstVisibleItemScrollOffset <= UnreadUiDefaults.AUTO_SCROLL_BOTTOM_OFFSET_TOLERANCE + val isNearBottom = firstVisibleItemIndex <= itemThreshold + isAtBottom || (!hasUnreadMessages && isNearBottom) + } + } + if (shouldAutoScroll) { + LaunchedEffect(messages.itemCount) { + if (!isScrollInProgress && messages.itemCount > 0) { + scrollToItem(0) + } + } + } +} + +@OptIn(FlowPreview::class) +@Composable +private fun UpdateUnreadCountPaged( + listState: LazyListState, + messages: LazyPagingItems, + onUnreadChange: (Long, Long) -> Unit, +) { + val currentOnUnreadChange by rememberUpdatedState(onUnreadChange) + + // Track remote message count to restart effect when remote messages change + // This fixes race condition when sending/receiving messages during debounce period + val remoteMessageCount by + remember(messages.itemCount) { + derivedStateOf { + (0 until messages.itemCount).count { i -> + val msg = messages[i] + msg != null && !msg.fromLocal + } + } + } + + // Mark messages as read after debounce period + // Handles both scrolling cases and when all unread messages are visible without scrolling + LaunchedEffect(remoteMessageCount, listState) { + snapshotFlow { + // Emit when scroll stops OR when at initial position (covers no-scroll case) + if (listState.isScrollInProgress) { + null // Scrolling in progress, don't emit + } else { + listState.firstVisibleItemIndex // Emit current position when not scrolling + } + } + .debounce(timeoutMillis = UnreadUiDefaults.SCROLL_DEBOUNCE_MILLIS) + .collectLatest { index -> + if (index != null) { + // Find the last (oldest in timeline, highest index) unread message in loaded items + val lastUnreadIndex = + (0 until messages.itemCount).lastOrNull { i -> + val msg = messages[i] + msg != null && !msg.read && !msg.fromLocal + } + + // If we're at/past the oldest unread, mark the first visible unread message + // Since newer messages have HIGHER timestamps, marking a newer message's timestamp + // will batch-mark all older messages via SQL: WHERE received_time <= timestamp + if (lastUnreadIndex != null && index <= lastUnreadIndex) { + // Find the first (newest in timeline, lowest index) visible unread message + val firstVisibleUnreadIndex = + (index until messages.itemCount).firstOrNull { i -> + val msg = messages[i] + msg != null && !msg.read && !msg.fromLocal + } + + if (firstVisibleUnreadIndex != null) { + val firstVisibleUnread = messages[firstVisibleUnreadIndex] + if (firstVisibleUnread != null) { + currentOnUnreadChange(firstVisibleUnread.uuid, firstVisibleUnread.receivedTime) + } + } + } + } + } + } +} + +@Composable +internal fun UnreadMessagesDivider(modifier: Modifier = Modifier) { + Row( + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + HorizontalDivider(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.new_messages_below), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + HorizontalDivider(modifier = Modifier.weight(1f)) + } +} + +@Composable +internal fun MessageStatusDialog( message: Message, nodes: List, resendOption: Boolean, @@ -278,115 +401,3 @@ private fun MessageStatusDialog( onDismiss = onDismiss, ) } - -@Composable -private fun rememberMessageRows( - messages: List, - showUnreadDivider: Boolean, - unreadDividerIndex: Int?, - initialUnreadMessageUuid: Long?, -) = remember(messages, showUnreadDivider, unreadDividerIndex, initialUnreadMessageUuid) { - buildList { - messages.forEachIndexed { index, message -> - add(MessageListRow.ChatMessage(index = index, message = message)) - if (showUnreadDivider && unreadDividerIndex == index) { - val key = initialUnreadMessageUuid?.let { "unread-divider-$it" } ?: "unread-divider-index-$index" - add(MessageListRow.UnreadDivider(key = key)) - } - } - } -} - -/** - * Calculates the index of the first unread remote message. - * - * We track unread state with two sources: the persisted timestamp of the last read message and the in-memory - * `Message.read` flag. The timestamp helps when the local flag state is stale (e.g. after app restarts), while the flag - * catches messages that are already marked read locally. We take the maximum of the two indices to target the oldest - * unread entry that still needs attention. The message list is newest-first, so we deliberately use `lastOrNull` for - * the timestamp branch to land on the oldest unread item after the stored mark. - */ -internal fun findEarliestUnreadIndex(messages: List, lastReadMessageTimestamp: Long?): Int? { - val remoteMessages = messages.withIndex().filter { !it.value.fromLocal } - if (remoteMessages.isEmpty()) { - return null - } - val timestampIndex = - lastReadMessageTimestamp?.let { timestamp -> - remoteMessages.lastOrNull { it.value.receivedTime > timestamp }?.index - } - val readFlagIndex = messages.indexOfLast { !it.read && !it.fromLocal }.takeIf { it != -1 } - return listOfNotNull(timestampIndex, readFlagIndex).maxOrNull() -} - -@Composable -private fun UnreadMessagesDivider(modifier: Modifier = Modifier) { - Row( - modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - HorizontalDivider(modifier = Modifier.weight(1f)) - Text( - text = stringResource(Res.string.new_messages_below), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - HorizontalDivider(modifier = Modifier.weight(1f)) - } -} - -@Composable -private fun AutoScrollToBottom( - listState: LazyListState, - list: List, - hasUnreadMessages: Boolean, - itemThreshold: Int = 3, -) = with(listState) { - val shouldAutoScroll by - remember(hasUnreadMessages) { - derivedStateOf { - val isAtBottom = - firstVisibleItemIndex == 0 && - firstVisibleItemScrollOffset <= UnreadUiDefaults.AUTO_SCROLL_BOTTOM_OFFSET_TOLERANCE - val isNearBottom = firstVisibleItemIndex <= itemThreshold - isAtBottom || (!hasUnreadMessages && isNearBottom) - } - } - if (shouldAutoScroll) { - LaunchedEffect(list) { - if (!isScrollInProgress) { - scrollToItem(0) - } - } - } -} - -@OptIn(FlowPreview::class) -@Composable -private fun UpdateUnreadCount( - listState: LazyListState, - messages: List, - onUnreadChanged: (Long, Long) -> Unit, -) { - val remoteMessageCount = remember(messages) { messages.count { !it.fromLocal } } - - LaunchedEffect(remoteMessageCount, listState) { - Timber.d("UpdateUnreadCount LaunchedEffect started/restarted, remoteMessageCount=$remoteMessageCount") - snapshotFlow { listState.firstVisibleItemIndex } - .debounce(timeoutMillis = UnreadUiDefaults.SCROLL_DEBOUNCE_MILLIS) - .collectLatest { index -> - Timber.d("Debounce triggered, index=$index, messages.size=${messages.size}") - val lastUnreadIndex = messages.indexOfLast { !it.read && !it.fromLocal } - Timber.d("lastUnreadIndex=$lastUnreadIndex") - // If user has scrolled past all unread messages, mark the last unread message as read - if (lastUnreadIndex != -1 && index <= lastUnreadIndex) { - val lastUnreadMessage = messages[lastUnreadIndex] - Timber.d("Marking last unread message as read: ${lastUnreadMessage.uuid}") - onUnreadChanged(lastUnreadMessage.uuid, lastUnreadMessage.receivedTime) - } else { - Timber.d("Not marking as read - no unread messages or user hasn't scrolled past them") - } - } - } -} diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index 07c0e8597..4be413f9b 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -20,8 +20,11 @@ package org.meshtastic.feature.messaging import android.os.RemoteException import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -82,22 +85,27 @@ constructor( val contactSettings: StateFlow> = packetRepository.getContactSettings().stateInWhileSubscribed(initialValue = emptyMap()) - private val contactKeyForMessages: MutableStateFlow = MutableStateFlow(null) - private val messagesForContactKey: StateFlow> = - contactKeyForMessages + private val contactKeyForPagedMessages: MutableStateFlow = MutableStateFlow(null) + private val pagedMessagesForContactKey: Flow> = + contactKeyForPagedMessages .filterNotNull() - .flatMapLatest { contactKey -> packetRepository.getMessagesFrom(contactKey, ::getNode) } - .stateInWhileSubscribed(initialValue = emptyList()) + .flatMapLatest { contactKey -> packetRepository.getMessagesFromPaged(contactKey, ::getNode) } + .cachedIn(viewModelScope) fun setTitle(title: String) { viewModelScope.launch { _title.value = title } } - fun getMessagesFrom(contactKey: String): StateFlow> { - contactKeyForMessages.value = contactKey - return messagesForContactKey + fun getMessagesFromPaged(contactKey: String): Flow> { + contactKeyForPagedMessages.value = contactKey + return pagedMessagesForContactKey } + fun getFirstUnreadMessageUuid(contactKey: String): Flow = + packetRepository.getFirstUnreadMessageUuid(contactKey) + + fun hasUnreadMessages(contactKey: String): Flow = packetRepository.hasUnreadMessages(contactKey) + fun toggleShowQuickChat() = toggle(_showQuickChat) { uiPrefs.showQuickChat = it } private fun toggle(state: MutableStateFlow, onChanged: (newValue: Boolean) -> Unit) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9f6df6420..ff444de5b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ datastore = "1.2.0" lifecycle = "2.10.0" navigation = "2.9.6" navigation3 = "1.0.0" +paging = "3.3.5" room = "2.8.4" # Kotlin @@ -59,7 +60,11 @@ androidx-navigation-compose = { module = "androidx.navigation:navigation-compose androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "navigation" } androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } +androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" } +androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } +androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "room" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.0" }