Merge main into fix-reporting-pii-mainthread

This commit is contained in:
Jason Ross
2026-07-04 03:21:30 -05:00
committed by GitHub
6 changed files with 176 additions and 35 deletions
@@ -6,6 +6,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
import org.libremail.data.local.entity.FolderUnreadCount
import org.libremail.data.local.entity.MessageEntity
@@ -36,11 +37,14 @@ interface MessageDao {
* by [pagingUnifiedFolderSearchSummaries] (issue #214). Profiling (see
* `docs/perf/issue-124-unified-inbox-paging.md`) showed the first page loads flat regardless of
* total cache size on the existing indices, so no `(folder, …)` index / schema migration is added.
* Breaks ties on the `id` primary key (issue #311): `timestampMillis` isn't unique — bulk mail
* shares a second — so without a unique tiebreaker two rows tied at a LIMIT/OFFSET page boundary
* could duplicate or skip across pages. `id` is in the projection, so the tiebreaker adds no index.
*/
@Query(
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
"WHERE folder = :folder AND inInbox = 1 ORDER BY timestampMillis DESC",
"WHERE folder = :folder AND inInbox = 1 ORDER BY timestampMillis DESC, id",
)
fun pagingUnifiedFolderSummaries(folder: String): PagingSource<Int, MessageSummary>
@@ -56,7 +60,7 @@ interface MessageDao {
@Query(
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
"WHERE accountId = :accountId AND folder = :folder AND inInbox = 1 ORDER BY timestampMillis DESC",
"WHERE accountId = :accountId AND folder = :folder AND inInbox = 1 ORDER BY timestampMillis DESC, id",
)
fun pagingFolderSummaries(accountId: String, folder: String): PagingSource<Int, MessageSummary>
@@ -70,13 +74,14 @@ interface MessageDao {
* server-search hits `MailRepository.searchServer` inserts — exactly what the old filter saw.
* Matches the Unicode-casefolded `*Fold` columns (issue #232) with a pattern built from the
* lowercased query, so search is case-insensitive beyond ASCII — unlike the old ASCII-only `LIKE`.
* Breaks ties on the `id` primary key for a total page order, like the browse pagers (issue #311).
*/
@Query(
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
"WHERE folder = :folder AND (senderFold LIKE :pattern ESCAPE '\\' OR " +
"senderEmailFold LIKE :pattern ESCAPE '\\' OR subjectFold LIKE :pattern ESCAPE '\\' OR " +
"snippetFold LIKE :pattern ESCAPE '\\') ORDER BY timestampMillis DESC",
"snippetFold LIKE :pattern ESCAPE '\\') ORDER BY timestampMillis DESC, id",
)
fun pagingUnifiedFolderSearchSummaries(folder: String, pattern: String): PagingSource<Int, MessageSummary>
@@ -91,7 +96,7 @@ interface MessageDao {
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
"WHERE accountId = :accountId AND folder = :folder AND (senderFold LIKE :pattern ESCAPE '\\' OR " +
"senderEmailFold LIKE :pattern ESCAPE '\\' OR subjectFold LIKE :pattern ESCAPE '\\' OR " +
"snippetFold LIKE :pattern ESCAPE '\\') ORDER BY timestampMillis DESC",
"snippetFold LIKE :pattern ESCAPE '\\') ORDER BY timestampMillis DESC, id",
)
fun pagingFolderSearchSummaries(
accountId: String,
@@ -190,6 +195,28 @@ interface MessageDao {
uid: Long,
)
/**
* Applies [updateHeaderContent] to every [messages] row in a single transaction (issue #310). A
* foreground sync / pull-to-refresh refreshes a whole recent window at once; running each row's
* UPDATE in its own implicit transaction fsyncs the journal once per message — N commits per folder
* per sync, amplified on the encrypted cache — so this collapses them into one commit. Refreshes the
* same display fields (plus the materialized [MessageEntity.uid] and the casefold search columns) as
* the single-row overload, still leaving cached bodies and optimistic read/star flags untouched.
*/
@Transaction
suspend fun updateHeaderContents(messages: List<MessageEntity>) {
for (message in messages) {
updateHeaderContent(
id = message.id,
sender = message.sender,
senderEmail = message.senderEmail,
subject = message.subject,
timestampMillis = message.timestampMillis,
uid = message.uid,
)
}
}
/** Marks rows as folder-synced (e.g. a former search-only row that the sync now returns). */
@Query("UPDATE messages SET inInbox = 1 WHERE id IN (:ids)")
suspend fun markSynced(ids: List<String>)
@@ -122,18 +122,11 @@ class MailSyncer @Inject constructor(
val ids = entities.map { it.id }
messageDao.insertNew(entities)
// Mark every fetched message as synced (upgrades any former search-only row) and refresh
// its display fields — without touching cached bodies or optimistic read/star flags.
// its display fields — without touching cached bodies or optimistic read/star flags. The
// per-row refreshes run in a single transaction (issue #310) so a whole recent window
// costs one commit instead of one fsync per message (amplified on the encrypted cache).
messageDao.markSynced(ids)
entities.forEach {
messageDao.updateHeaderContent(
id = it.id,
sender = it.sender,
senderEmail = it.senderEmail,
subject = it.subject,
timestampMillis = it.timestampMillis,
uid = it.uid,
)
}
messageDao.updateHeaderContents(entities)
// Reconcile server-side deletions ONLY within the fetched recent-UID window, so older
// history paged in by the background backfill (issue #12) survives each foreground sync
// instead of being wiped by a whole-folder "not in the recent 50" delete. Bound the
@@ -3,6 +3,7 @@ package org.libremail.di
import android.content.Context
import androidx.room.Room
import androidx.room.migration.Migration
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import dagger.Module
import dagger.Provides
@@ -46,30 +47,39 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object DatabaseModule {
/**
* Every migration the Room builder registers, in one named list so [provideDatabase] and the
* "registered == declared" safety-net test (issue #312) read the same source. There is deliberately
* no destructive fallback (see below), so a migration authored + schema-committed but forgotten here
* passes every replay test yet crash-loops ALL upgrading users at first DB open; `MigrationTest`
* asserts this list equals the reflectively-discovered set of every `Migration` val in Migrations.kt.
*/
val ALL_MIGRATIONS: Array<Migration> = arrayOf(
MIGRATION_1_2,
MIGRATION_2_3,
MIGRATION_3_4,
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10,
MIGRATION_10_11,
MIGRATION_11_12,
MIGRATION_12_13,
MIGRATION_13_14,
MIGRATION_14_15,
MIGRATION_15_16,
MIGRATION_16_17,
MIGRATION_17_18,
MIGRATION_18_19,
)
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context, provisioner: DatabaseProvisioner): LibreMailDatabase =
Room.databaseBuilder(context, LibreMailDatabase::class.java, DB_NAME)
.addMigrations(
MIGRATION_1_2,
MIGRATION_2_3,
MIGRATION_3_4,
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10,
MIGRATION_10_11,
MIGRATION_11_12,
MIGRATION_12_13,
MIGRATION_13_14,
MIGRATION_14_15,
MIGRATION_15_16,
MIGRATION_16_17,
MIGRATION_17_18,
MIGRATION_18_19,
)
.addMigrations(*ALL_MIGRATIONS)
// No destructive fallback: the migration chain is complete, and silently dropping the
// mail/message tables would lose cached data. A missing migration should fail loudly in
// testing instead.