Fix large conversation load - #321
Conversation
| /** | ||
| * Newest-first window over a conversation, re-queried whenever the conversation changes or | ||
| * [windowSizes] asks for a larger window. | ||
| */ | ||
| fun getConversationMessages( | ||
| conversationId: ConversationId, | ||
| windowSizes: Flow<Int>, | ||
| ): Flow<ConversationMessagesWindow> |
There was a problem hiding this comment.
The implementation of this seems to be call queryConversationMessages() which traverses ReversedCursor, so each returned messages list is ordered oldest first, so "Newest-first window" might not be descriptive
| snapshotFlow { | ||
| val layoutInfo = listState.layoutInfo | ||
| val lastVisibleItemIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index | ||
|
|
||
| // Before the first measure pass nothing is visible yet, and asking then would grow the | ||
| // window on every conversation open. | ||
| lastVisibleItemIndex != null && | ||
| lastVisibleItemIndex >= layoutInfo.totalItemsCount - LOAD_OLDER_MESSAGES_THRESHOLD | ||
| } | ||
| .distinctUntilChanged() |
There was a problem hiding this comment.
snapshotFlow (https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#snapshotFlow(kotlin.Function0)) is already documented to be similar to that of Flow.distinctUntilChanged)
|
|
||
| intent.putExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, scrollToPosition); | ||
| intent.putExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, position); | ||
| if (message.hasAttachments()) { |
There was a problem hiding this comment.
This position can change between the widget tap and the conversation query. For example, with messages 1…20 ordered oldest to newest, tapping message 10 sends position 10; if message 21 arrives before the query, messagePositionToDisplayIndex() now targets message 11. The previous oldest-first position remained valid for this append.
e.g. if adb shell am kill --user current com.android.messaging.debug is done with a widget open for a conversation, the conversation widget may no longer receive updates. Tapping on a message in that stale widget after the conversation has received more messages would scroll it to the wrong position. In more natrual occurrences, the Messaging app could be killed by lmkd killing background apps under system memory pressure, which can cause the same receiver-registration loss
I think it would be better to use tapped message ID through the launch request and resolve its position in the loaded window, expanding it if needed
| internal data class ConversationMessagesWindow( | ||
| val messages: List<ConversationMessageData>, | ||
| val hasMore: Boolean, | ||
| ) |
There was a problem hiding this comment.
hasMore appears to be derived from ConversationsRepository code like this:
ConversationMessagesWindow(
messages = messages,
hasMore = messages.size >= windowSize,
)However, messages.size >= windowSize does not prove that it has more, since it's possible for messages.size == windowSize. There would be no more messages, but hasMore would still be true, which contradicts the variable name
| * <p>The limit is inlined rather than bound because a bound argument carries string affinity | ||
| * here; callers validate it. |
There was a problem hiding this comment.
This comment seems a little misleading, as SQLite documentation permits binding limits even if they're bound as strings: https://www.sqlite.org/lang_select.html#the_limit_clause
AOSP has this exact pattern in ContactsProvider: https://github.com/GrapheneOS/platform_packages_providers_ContactsProvider/blob/17/src/com/android/providers/contacts/picker/ContactsPickerSessionProvider.java#L152-L167
|
I think this PR overall is a net improvement since it addresses initial conversation loading while preserving the existing list-based UI. For the limitations, it does not bound memory during deep browsing: the window keeps growing. The benchmarks in the PR description would need to be clear whether it's an SMS-onlyl; the fixture with 80000 messages contains only text SMS and would very likely not reflect real-user usage of having a mix of attachment types (audio, image/video, vcards, etc.) Also each expansion rereads and remaps the expanded window, and subsequent invalidations repeat that work. The code:
So for example:
Growing from 500 to 1000 messages sends all 1000 through the attachment pipeline again. It also retains the existing loading/error handling rather than adding separate history-load states and retries. If e.g. we wanted to also support features such as being able to go to the conversation message position that an attachment came from or searching in messages and being able to jump to the message's position in the conversation, the current window design would still require us to load all messages until the window covers it In the long term, using Paging 3 could address these limitations with bounded page retention, incremental queries, appropriate refresh keys, and loading UI and would make it easier to support new features such as searching in messages efficiently. Google Messages already uses Paging 3 for both inbox conversation list and the messages within conversations, and Signal also uses paging |
| /** | ||
| * Seeds a single very large conversation for large-conversation performance testing. | ||
| * | ||
| * Kept out of [seedTestData] so that the instrumented test suite, which seeds on every run, is | ||
| * not slowed down by it. | ||
| */ | ||
| fun seedHugeConversation() { | ||
| val db = DataModel.get().getDatabase() | ||
|
|
||
| val selfId = findSelfParticipantId(db) ?: run { | ||
| LogUtil.w(TAG, "No self participant found \u2014 open the app at least once before seeding") | ||
| return | ||
| } | ||
|
|
||
| db.withTransaction { | ||
| val victorId = upsertParticipant( | ||
| db, | ||
| "${TEST_PHONE_PREFIX}099999", | ||
| "Victor Voluminous", | ||
| "Victor", | ||
| ) | ||
| seedScenarioHuge( | ||
| db = db, | ||
| selfId = selfId, | ||
| victorId = victorId, | ||
| now = System.currentTimeMillis(), | ||
| ) | ||
| } | ||
|
|
||
| MessagingContentProvider.notifyConversationListChanged() | ||
| LogUtil.d(TAG, "Huge conversation seeded successfully") | ||
| } | ||
|
|
||
| /** | ||
| * 1:1 SMS thread with Victor holding [HUGE_CONVERSATION_MESSAGE_COUNT] messages, one per minute. | ||
| */ | ||
| private fun seedScenarioHuge( | ||
| db: DatabaseWrapper, | ||
| selfId: String, | ||
| victorId: String, | ||
| now: Long, | ||
| ) { | ||
| val baseTime = now - HUGE_CONVERSATION_MESSAGE_COUNT * MINUTES | ||
| val conversationId = createConversation( | ||
| db = db, | ||
| name = "Victor Voluminous", | ||
| selfId = selfId, | ||
| participantIds = listOf(victorId), | ||
| sortTimestamp = baseTime, | ||
| ) | ||
|
|
||
| var latestMessageId = 0L | ||
| var latestTime = baseTime | ||
| for (index in 0 until HUGE_CONVERSATION_MESSAGE_COUNT) { | ||
| val isIncoming = index % 3 != 1 | ||
| latestTime = baseTime + index * MINUTES | ||
| latestMessageId = insertTextMessage( | ||
| db = db, | ||
| conversationId = conversationId, | ||
| senderId = if (isIncoming) victorId else selfId, | ||
| selfId = selfId, | ||
| text = "Message $index of $HUGE_CONVERSATION_MESSAGE_COUNT", | ||
| status = when { | ||
| isIncoming -> MessageData.BUGLE_STATUS_INCOMING_COMPLETE | ||
| else -> MessageData.BUGLE_STATUS_OUTGOING_COMPLETE | ||
| }, | ||
| protocol = MessageData.PROTOCOL_SMS, | ||
| timestamp = latestTime, | ||
| ) | ||
| } | ||
|
|
||
| finalizeConversation( | ||
| db = db, | ||
| conversationId = conversationId, | ||
| latestMessageId = latestMessageId, | ||
| latestTimestamp = latestTime, | ||
| snippetText = "Message ${HUGE_CONVERSATION_MESSAGE_COUNT - 1} of " + | ||
| "$HUGE_CONVERSATION_MESSAGE_COUNT", | ||
| ) | ||
| } |
There was a problem hiding this comment.
This only seeds a text-only 80k conversation. If this was used for the benchmarks in the PR, it should be clarified that the benchmark is SMS-only. Actual user conversations would have a mix of attachments (audio, image/video, vcards, etc.) that could affect the benchmark results for both speed and memory usage
| return combine( | ||
| conversationsRepository | ||
| .getConversationMessages(conversationId = conversationId) | ||
| .onEach { messages -> | ||
| currentMessages.value = messages | ||
| .getConversationMessages( | ||
| conversationId = conversationId, | ||
| windowSizes = windowSizes, | ||
| ) | ||
| .onEach { window -> | ||
| loadedMessageCounts.value = window.messages.size | ||
| hasOlderMessages.value = window.hasMore | ||
|
|
||
| if (window.hasMore && hasPendingLoadOlderMessages.value) { | ||
| growWindow() | ||
| } | ||
| } | ||
| .map { messages -> | ||
| messages | ||
| .map { window -> | ||
| window | ||
| .messages | ||
| .asSequence() | ||
| .mapNotNull(conversationMessageUiModelMapper::map) | ||
| .toImmutableList() | ||
| } | ||
| .map(::withAudioDurations) | ||
| .flatMapLatest { messages -> | ||
| observeMessagesWithVCardMetadata( | ||
| messages = messages, | ||
| ) | ||
| }, |
There was a problem hiding this comment.
Growing window design means attachments would be processed again even for messages in previous window. e.g. If initially we query and map 500 messages, in order to expand to a window of 1000, we need to query and map the 500 old messages together with the 500 new messages
The pipeline explicitly maps the complete result, calls withAudioDurations, then starts observeMessagesWithVCardMetadata
Existing caches appear limited, e.g.
- ResolveAudioDurationMillis.kt caches only 256 successful duration results
- The parsed card cache in VCardEntryRepository.kt holds only five URI entries, so larger sets can require repeated parsing. A parsed-card cache hit also does not eliminate embedded-avatar processing:
VCardEntrySummarizer.avatarPhoto()invokes the downscaler during metadata mapping. Unlike audio'sawaitAll(), contact cards can initially emit Loading metadata, so this work need not all precede the first message frame
2b846ba to
0b93073
Compare
inthewaves
left a comment
There was a problem hiding this comment.
Looks good especially for initial loading, the other issues can be fixed in the long term with Paging
m4pl
left a comment
There was a problem hiding this comment.
Looks good. Also checked it on the device.
Fixes #304
The screen loaded the whole conversation in one query and held every message in memory. The query groups on
messages._idand orders byreceived_timestamp, so SQLite sorted the entire thread into temp b-trees before it could return a single row - 35 s and ~98 MB of Java heap for an 80000-message conversation, with an empty screen the whole time.Fix:
LIMITin a subquery overmessages, before the joins and theGROUP BY. ALIMITon the outer statement doesn't help - the sort still runs over the whole conversation first. Bounding the subquery bounds both temp b-trees to the window.(conversation_id, received_timestamp)index, schema v5.index_messages_sortcan't serve this query:statussits between those two columns and is filtered with a non-indexable<>.Benchmarks (SMS-only):
Scrolling back through the 80,000-message thread, window doubling each time:
Java heap after flinging back to 4,000 loaded messages: 76 MB - still below what the old code needed just to open the thread.
One-time v4 -> v5 upgrade on that database: 36 ms. The index costs 1.4 MB for 80,000 messages.