Skip to content

Fix large conversation load - #321

Merged
thestinger merged 9 commits into
GrapheneOS:mainfrom
RankoR-GOS:fix-large-conversation-load
Sep 22, 2026
Merged

thestinger merged 9 commits into
GrapheneOS:mainfrom
RankoR-GOS:fix-large-conversation-load

Conversation

@RankoR

@RankoR RankoR commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Fixes #304

The screen loaded the whole conversation in one query and held every message in memory. The query groups on messages._id and orders by received_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:

  • LIMIT in a subquery over messages, before the joins and the GROUP BY. A LIMIT on 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.
  • A (conversation_id, received_timestamp) index, schema v5. index_messages_sort can't serve this query: status sits between those two columns and is filtered with a non-indexable <>.
  • One window that grows, not pages. Open on the newest 500, double it when the user scrolls within 100 items of the oldest loaded message. It stays one query, one cursor, one observer URI, so invalidation, scroll position and "nothing older left" (a short row count) stay trivial. Paging would have meant replacing that pipeline - the widget, the selection delegate and the photo viewer all read the same URI - for the same user-visible result.

Benchmarks (SMS-only):

Conversation Open -> messages on screen SQLite query Rows loaded Java heap
80,000 messages 35,453 ms -> 100 ms 35,153 ms -> 32 ms 80,000 -> 500 97.6 MB -> 27.5 MB
300 messages 35 ms -> 57 ms 13 ms -> 29 ms 300 26.3 MB -> 26.2 MB
40 messages 9 ms -> 20 ms 3 ms -> 7 ms 40 26.2 MB -> 26.2 MB

Scrolling back through the 80,000-message thread, window doubling each time:

Window 500 1,000 2,000 4,000
Query 30 ms 60 ms 70 ms 250 ms
Query -> list updated 18 ms 47 ms 39 ms 73 ms

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.

@RankoR
RankoR marked this pull request as ready for review September 17, 2026 21:07
@RankoR
RankoR requested review from inthewaves, m4pl and sdsantos and removed request for inthewaves and m4pl September 17, 2026 21:07
Comment on lines +58 to +65
/**
* 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>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +413 to +422
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()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()) {

@inthewaves inthewaves Sep 21, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +660 to +661
* <p>The limit is inlined rather than bound because a bound argument carries string affinity
* here; callers validate it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@inthewaves

inthewaves commented Sep 21, 2026

Copy link
Copy Markdown
Member

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:

  1. Queries using the new windowSize (src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt:157).
  2. Builds fresh message objects from the entire returned cursor (src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt:511).
  3. Maps every message in that window into UI models (src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegate.kt:181).

So for example:

  • Open: query and map 500 messages
  • Expand to 1000: query and map 1,000 messages
  • Total: 1,500 messages processed

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

Comment on lines +2108 to +2187
/**
* 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",
)
}

@inthewaves inthewaves Sep 21, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines 167 to 193
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,
)
},

@inthewaves inthewaves Sep 21, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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's awaitAll(), contact cards can initially emit Loading metadata, so this work need not all precede the first message frame

@RankoR
RankoR force-pushed the fix-large-conversation-load branch from 2b846ba to 0b93073 Compare September 21, 2026 19:05
@RankoR
RankoR requested a review from inthewaves September 21, 2026 19:32

@inthewaves inthewaves left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good especially for initial loading, the other issues can be fixed in the long term with Paging

@m4pl m4pl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. Also checked it on the device.

@thestinger
thestinger merged commit b76fe1a into GrapheneOS:main Sep 22, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v13: App stuck loading messages indefinitely on owner profile in long conversations (~80k messages)

4 participants