feat(search): Unicode-aware case-insensitive search via casefold columns

Restores Unicode-aware case-insensitive substring search (approach A of #227).
Paging (#223) moved search to a SQL LIKE scan whose case-folding is ASCII-only,
so non-ASCII terms stopped matching case-insensitively.

Adds per-field casefold columns to `messages` (senderFold/senderEmailFold/
subjectFold/snippetFold), each = Kotlin lowercase() of its source (Unicode-aware).
Per-field (not one concatenated column) because the fields are maintained by
partial UPDATEs that don't carry all four: toEntity sets all folds, updateBody
keeps snippetFold in sync, updateHeaderContent keeps the header folds -- via thin
DAO default-method wrappers so the five call sites are unchanged. Search matches
the fold columns with a pattern built from the lowercased query.

Schema v18->v19 (additive; ASCII lower() backfill, non-ASCII rows re-fold on next
write). Adds a MigrationTest v18->v19 case and a repo test asserting the query is
casefolded.

Closes #232

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 11:41:53 -05:00
co-authored by Claude Opus 4.8
parent 2a2db6d0eb
commit aae4f2218b
10 changed files with 612 additions and 18 deletions
@@ -0,0 +1,495 @@
{
"formatVersion": 1,
"database": {
"version": 19,
"identityHash": "eb8510d29c6bd5fdb450ab835b6a770e",
"entities": [
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, `uid` INTEGER NOT NULL DEFAULT 0, `senderFold` TEXT NOT NULL DEFAULT '', `senderEmailFold` TEXT NOT NULL DEFAULT '', `subjectFold` TEXT NOT NULL DEFAULT '', `snippetFold` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderEmail",
"columnName": "senderEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snippet",
"columnName": "snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHtml",
"columnName": "isHtml",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestampMillis",
"columnName": "timestampMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "isRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'INBOX'"
},
{
"fieldPath": "inInbox",
"columnName": "inInbox",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyFetched",
"columnName": "bodyFetched",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "senderFold",
"columnName": "senderFold",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "senderEmailFold",
"columnName": "senderEmailFold",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subjectFold",
"columnName": "subjectFold",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "snippetFold",
"columnName": "snippetFold",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_messages_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
},
{
"name": "index_messages_timestampMillis",
"unique": false,
"columnNames": [
"timestampMillis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
},
{
"name": "index_messages_accountId_folder_uid",
"unique": false,
"columnNames": [
"accountId",
"folder",
"uid"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
}
]
},
{
"tableName": "attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `contentId` TEXT, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "partIndex",
"columnName": "partIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentId",
"columnName": "contentId",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId",
"partIndex"
]
},
"indices": [
{
"name": "index_attachments_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": [
{
"table": "messages",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"messageId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "outbox",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, `attachments` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastError",
"columnName": "lastError",
"affinity": "TEXT"
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "drafts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT"
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, `hierarchyDelimiter` TEXT, PRIMARY KEY(`accountId`, `fullName`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fullName",
"columnName": "fullName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "selectable",
"columnName": "selectable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "specialUse",
"columnName": "specialUse",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "hierarchyDelimiter",
"columnName": "hierarchyDelimiter",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"fullName"
]
}
},
{
"tableName": "backfill_progress",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nextBeforeUid",
"columnName": "nextBeforeUid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "complete",
"columnName": "complete",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"folder"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eb8510d29c6bd5fdb450ab835b6a770e')"
]
}
}
@@ -37,6 +37,32 @@ class MigrationTest {
FrameworkSQLiteOpenHelperFactory(),
)
/** v18 -> v19 (issue #232): the four casefold search columns appear, backfilled from their source. */
@Test
fun migrate18To19_addsAndBackfillsCasefoldSearchColumns() {
helper.createDatabase(TEST_DB, 18).apply {
execSQL(
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched, uid) VALUES " +
"('acct:INBOX:1', 'acct', 'Ada LOVELACE', 'Ada@Example.ORG', 'HELLO There', 'World Preview', " +
"'', 0, 1000, 0, 0, 'INBOX', 1, 1, 1)",
)
close()
}
val db = helper.runMigrationsAndValidate(TEST_DB, 19, true, MIGRATION_18_19)
db.query(
"SELECT senderFold, senderEmailFold, subjectFold, snippetFold FROM messages WHERE id = 'acct:INBOX:1'",
).use { c ->
assertTrue(c.moveToFirst())
assertEquals("ada lovelace", c.getString(0))
assertEquals("ada@example.org", c.getString(1))
assertEquals("hello there", c.getString(2))
assertEquals("world preview", c.getString(3))
}
}
/** v11 -> v12 (PR #54): `folders.specialUse` appears defaulting to 0 and existing data survives. */
@Test
fun migrate11To12_defaultsExistingFoldersToNotSpecialUse() {
@@ -35,7 +35,7 @@ import org.libremail.data.local.entity.OutboxEntity
FolderEntity::class,
BackfillProgressEntity::class,
],
version = 18,
version = 19,
exportSchema = true,
)
abstract class LibreMailDatabase : RoomDatabase() {
@@ -149,6 +149,10 @@ internal fun FetchedMessage.toEntity(accountId: String, folder: String, inInbox:
inInbox = inInbox,
bodyFetched = false,
uid = uid.toLongOrNull() ?: 0L,
senderFold = sender.lowercase(),
senderEmailFold = senderEmail.lowercase(),
subjectFold = subject.lowercase(),
snippetFold = "",
)
internal fun FolderEntity.toDomain(): Folder = Folder(
@@ -359,3 +359,24 @@ val MIGRATION_17_18 = object : Migration(17, 18) {
db.execSQL("ALTER TABLE `outbox` ADD COLUMN `attachments` TEXT NOT NULL DEFAULT ''")
}
}
/**
* v18 -> v19: Unicode-aware case-insensitive search (issue #232; preserves existing data). SQLite's
* `LIKE` folds case only for ASCII, so search now matches against `lowercase()` copies of the four
* searchable fields. Adds the fold columns and backfills them. The backfill's SQL `lower()` is
* ASCII-only, so existing rows are casefolded for ASCII here and fully re-folded (via the Unicode-aware
* Kotlin `lowercase()` in the mapper / DAO) on their next write or sync.
*/
val MIGRATION_18_19 = object : Migration(18, 19) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `messages` ADD COLUMN `senderFold` TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE `messages` ADD COLUMN `senderEmailFold` TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE `messages` ADD COLUMN `subjectFold` TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE `messages` ADD COLUMN `snippetFold` TEXT NOT NULL DEFAULT ''")
db.execSQL(
"UPDATE `messages` SET `senderFold` = lower(`sender`), " +
"`senderEmailFold` = lower(`senderEmail`), `subjectFold` = lower(`subject`), " +
"`snippetFold` = lower(`snippet`)",
)
}
}
@@ -67,15 +67,16 @@ interface MessageDao {
* Scans the same columns the old in-memory search filter (`matchesSearch`) did, but in SQL so a
* search over a large folder loads only the visible window. Unlike the browse pagers this does
* *not* filter `inInbox`, so it surfaces both synced rows and the transient `inInbox = 0`
* server-search hits `MailRepository.searchServer` inserts — exactly what the old filter saw. LIKE
* is case-insensitive for ASCII (SQLite's default), matching `matchesSearch`'s common case.
* 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`.
*/
@Query(
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
"WHERE folder = :folder AND (sender LIKE :pattern ESCAPE '\\' OR " +
"senderEmail LIKE :pattern ESCAPE '\\' OR subject LIKE :pattern ESCAPE '\\' OR " +
"snippet LIKE :pattern ESCAPE '\\') ORDER BY timestampMillis DESC",
"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",
)
fun pagingUnifiedFolderSearchSummaries(folder: String, pattern: String): PagingSource<Int, MessageSummary>
@@ -88,9 +89,9 @@ 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 (sender LIKE :pattern ESCAPE '\\' OR " +
"senderEmail LIKE :pattern ESCAPE '\\' OR subject LIKE :pattern ESCAPE '\\' OR " +
"snippet LIKE :pattern ESCAPE '\\') ORDER BY timestampMillis DESC",
"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",
)
fun pagingFolderSearchSummaries(
accountId: String,
@@ -157,12 +158,8 @@ interface MessageDao {
* Refreshes the display fields (and the materialized [MessageEntity.uid], keeping it fresh for
* rows migrated before the column existed) from the server without touching the cached body, the
* local read/star flags (which may hold an optimistic change the server hasn't reflected yet), or
* the inbox membership.
* the inbox membership. Keeps the header casefold search columns (issue #232) in sync.
*/
@Query(
"UPDATE messages SET sender = :sender, senderEmail = :senderEmail, subject = :subject, " +
"timestampMillis = :timestampMillis, uid = :uid WHERE id = :id",
)
suspend fun updateHeaderContent(
id: String,
sender: String,
@@ -170,14 +167,42 @@ interface MessageDao {
subject: String,
timestampMillis: Long,
uid: Long,
) = updateHeaderContentInternal(
id, sender, senderEmail, subject,
sender.lowercase(), senderEmail.lowercase(), subject.lowercase(),
timestampMillis, uid,
)
@Query(
"UPDATE messages SET sender = :sender, senderEmail = :senderEmail, subject = :subject, " +
"senderFold = :senderFold, senderEmailFold = :senderEmailFold, subjectFold = :subjectFold, " +
"timestampMillis = :timestampMillis, uid = :uid WHERE id = :id",
)
suspend fun updateHeaderContentInternal(
id: String,
sender: String,
senderEmail: String,
subject: String,
senderFold: String,
senderEmailFold: String,
subjectFold: String,
timestampMillis: Long,
uid: Long,
)
/** 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>)
@Query("UPDATE messages SET body = :body, isHtml = :isHtml, snippet = :snippet, bodyFetched = 1 WHERE id = :id")
suspend fun updateBody(id: String, body: String, isHtml: Boolean, snippet: String)
/** Sets the fetched body + its derived snippet, keeping [MessageEntity.snippetFold] (search) in sync. */
suspend fun updateBody(id: String, body: String, isHtml: Boolean, snippet: String) =
updateBodyInternal(id, body, isHtml, snippet, snippet.lowercase())
@Query(
"UPDATE messages SET body = :body, isHtml = :isHtml, snippet = :snippet, " +
"snippetFold = :snippetFold, bodyFetched = 1 WHERE id = :id",
)
suspend fun updateBodyInternal(id: String, body: String, isHtml: Boolean, snippet: String, snippetFold: String)
@Query("UPDATE messages SET isRead = :isRead WHERE id = :id")
suspend fun setRead(id: String, isRead: Boolean)
@@ -38,4 +38,12 @@ data class MessageEntity(
* (refreshed to the real UID on the next sync).
*/
@ColumnInfo(defaultValue = "0") val uid: Long = 0L,
// Unicode-casefolded copies of the searchable fields (issue #232). SQLite's LIKE folds case only for
// ASCII, so search matches against these `lowercase()` copies (Kotlin's lowercase is Unicode-aware)
// for case-insensitive Unicode search. Kept in sync wherever their source is written — toEntity,
// MessageDao.updateBody (snippet), and MessageDao.updateHeaderContent (headers).
@ColumnInfo(defaultValue = "") val senderFold: String = "",
@ColumnInfo(defaultValue = "") val senderEmailFold: String = "",
@ColumnInfo(defaultValue = "") val subjectFold: String = "",
@ColumnInfo(defaultValue = "") val snippetFold: String = "",
)
@@ -106,14 +106,14 @@ class MailRepositoryImpl @Inject constructor(
mailboxPager { messageDao.pagingFolderSummaries(accountId, folder) }
override fun pagedUnifiedSearchMessages(folder: String, query: String): Flow<PagingData<Message>> =
mailboxPager { messageDao.pagingUnifiedFolderSearchSummaries(folder, likePattern(query)) }
mailboxPager { messageDao.pagingUnifiedFolderSearchSummaries(folder, likePattern(query.lowercase())) }
override fun pagedFolderSearchMessages(
accountId: String,
folder: String,
query: String,
): Flow<PagingData<Message>> =
mailboxPager { messageDao.pagingFolderSearchSummaries(accountId, folder, likePattern(query)) }
mailboxPager { messageDao.pagingFolderSearchSummaries(accountId, folder, likePattern(query.lowercase())) }
/**
* Shared [Pager] for the per-account and search mailbox lists (issue #214). Same window sizing as
@@ -24,6 +24,7 @@ import org.libremail.data.local.MIGRATION_14_15
import org.libremail.data.local.MIGRATION_15_16
import org.libremail.data.local.MIGRATION_16_17
import org.libremail.data.local.MIGRATION_17_18
import org.libremail.data.local.MIGRATION_18_19
import org.libremail.data.local.MIGRATION_1_2
import org.libremail.data.local.MIGRATION_2_3
import org.libremail.data.local.MIGRATION_3_4
@@ -67,6 +68,7 @@ object DatabaseModule {
MIGRATION_15_16,
MIGRATION_16_17,
MIGRATION_17_18,
MIGRATION_18_19,
)
// 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
@@ -123,6 +123,19 @@ class MailRepositoryImplTest {
assertEquals("%50\\%\\_off%", pattern.captured)
}
@Test
fun `pagedUnifiedSearchMessages lowercases the query for Unicode case-insensitive matching`() = runTest {
val pattern = slot<String>()
every { messageDao.pagingUnifiedFolderSearchSummaries("INBOX", capture(pattern)) } returns
FakeSummaryPagingSource(listOf(messageSummary("1", "INBOX")))
repository.pagedUnifiedSearchMessages("INBOX", "ÄPFEL").asSnapshot()
// The query is casefolded (Kotlin's Unicode-aware lowercase) before the LIKE pattern is built, so
// it matches the lowercase()d *Fold columns regardless of case, beyond ASCII (issue #232).
assertEquals("%äpfel%", pattern.captured)
}
@Test
fun `pagedFolderSearchMessages scopes the paged search to the account and folder`() = runTest {
val pattern = slot<String>()