From aae4f2218bd3156a5f619e8bfd3428fcd6bcc4c9 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 3 Jul 2026 11:41:53 -0500 Subject: [PATCH] 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 --- .../19.json | 495 ++++++++++++++++++ .../org/libremail/data/local/MigrationTest.kt | 26 + .../libremail/data/local/LibreMailDatabase.kt | 2 +- .../org/libremail/data/local/Mappers.kt | 4 + .../org/libremail/data/local/Migrations.kt | 21 + .../libremail/data/local/dao/MessageDao.kt | 55 +- .../data/local/entity/MessageEntity.kt | 8 + .../data/repository/MailRepositoryImpl.kt | 4 +- .../kotlin/org/libremail/di/DatabaseModule.kt | 2 + .../data/repository/MailRepositoryImplTest.kt | 13 + 10 files changed, 612 insertions(+), 18 deletions(-) create mode 100644 app/schemas/org.libremail.data.local.LibreMailDatabase/19.json diff --git a/app/schemas/org.libremail.data.local.LibreMailDatabase/19.json b/app/schemas/org.libremail.data.local.LibreMailDatabase/19.json new file mode 100644 index 0000000..e13af7a --- /dev/null +++ b/app/schemas/org.libremail.data.local.LibreMailDatabase/19.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt index e2939a8..f5fc1ff 100644 --- a/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt @@ -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() { diff --git a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt index af42bfc..8354a5d 100644 --- a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt +++ b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt @@ -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() { diff --git a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt index cc464cb..2bc7c01 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -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( diff --git a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt index 1777f53..5b7d62c 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt @@ -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`)", + ) + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt index 4f69bd2..c6079aa 100644 --- a/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt +++ b/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt @@ -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 @@ -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) - @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) diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/MessageEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/MessageEntity.kt index 0fb4b08..97b0fae 100644 --- a/app/src/main/kotlin/org/libremail/data/local/entity/MessageEntity.kt +++ b/app/src/main/kotlin/org/libremail/data/local/entity/MessageEntity.kt @@ -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 = "", ) diff --git a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt index 5414edc..cc0125f 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -106,14 +106,14 @@ class MailRepositoryImpl @Inject constructor( mailboxPager { messageDao.pagingFolderSummaries(accountId, folder) } override fun pagedUnifiedSearchMessages(folder: String, query: String): Flow> = - mailboxPager { messageDao.pagingUnifiedFolderSearchSummaries(folder, likePattern(query)) } + mailboxPager { messageDao.pagingUnifiedFolderSearchSummaries(folder, likePattern(query.lowercase())) } override fun pagedFolderSearchMessages( accountId: String, folder: String, query: String, ): Flow> = - 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 diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt index 4143f96..2c69a34 100644 --- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt @@ -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 diff --git a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt index 4e63d1c..c0fc895 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -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() + 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()