Skip to content

feat(search): match titles regardless of accents - #417

Open
Pierroons wants to merge 2 commits into
oxyroid:masterfrom
Pierroons:feat/accent-insensitive-search
Open

feat(search): match titles regardless of accents#417
Pierroons wants to merge 2 commits into
oxyroid:masterfrom
Pierroons:feat/accent-insensitive-search

Conversation

@Pierroons

Copy link
Copy Markdown
Contributor

Searching "prenom" returns nothing while "prénom" finds "Le Prénom".

SQLite's LIKE ignores case for ASCII but never diacritics, so on a French
catalogue a large share of titles is unreachable to anyone not typing the
accent — which, on a TV remote, is most of the time. Measured on a 40 971
channel catalogue: 4 882 titles carry a diacritic, 12 % of the whole.

Approach

Titles are stored a second time in a folded form — NFD, combining marks
dropped, lowercased with Locale.ROOT — and queries are folded the same way
before reaching the DAO. The comparison stays symmetric: accented or not,
either side matches.

normalizeForSearch is the single definition of that fold. The migration
backfills existing rows through it, in Kotlin, rather than through a stack of
SQL REPLACE calls: the two would drift the first time either side is touched,
and a drift here is silent — rows simply stop matching.

Locale.ROOT is deliberate. A Turkish locale lowercases I to a dotless ı,
which would quietly make those titles unsearchable for those users.

titleNormalized sits outside the constructor on purpose. A data class only
copies constructor parameters, so copy(title = …) recomputes it instead of
carrying a stale value that would drop the channel out of every search — the
invariant holds by construction rather than by remembering to maintain it.

No index on the column: '%query%' cannot use one, and it would only slow the
bulk inserts a resubscription performs on tens of thousands of rows.

Folding happens in the repositories rather than at each call site, so every
screen searching channels or categories behaves the same way.

Tests

Five unit tests on the fold — accents, case under a Turkish default locale,
scripts without combining marks, punctuation, empty input — and a migration
test asserting existing rows are backfilled, including past the first batch.
The backfill matters most for users who have run the app for months and never
re-import their catalogue.

Verified on device against 40 971 rows: migration completes at startup, every
row backfilled, and "prenom" and "AMELIE" return the accented titles.

"prenom" returned nothing while "prénom" found "Le Prénom". SQLite's LIKE
ignores case for ASCII but never diacritics, so 4 882 of the 40 971 titles
in a French catalogue — 12 % — were unreachable to anyone who did not type
the accent, which on a TV remote is most of the time.

Titles are now stored a second time in a folded form (NFD, combining marks
dropped, lowercased with Locale.ROOT) and queries are folded the same way
before they reach the DAO. The comparison stays symmetric: accented or not,
either side matches.

normalizeForSearch is the single definition of that fold. The migration
backfills existing rows through it in Kotlin rather than through a stack of
SQL REPLACE calls, so the two can never drift — and a drift here is silent,
rows simply stop matching.

titleNormalized sits outside the constructor on purpose: a data class only
copies constructor parameters, so copy(title = …) recomputes it instead of
carrying a stale value that would drop the channel out of every search.

Folding happens in the repositories rather than at each call site, so every
screen searching channels or categories behaves the same way.

No index on the column: '%query%' cannot use one, and it would only slow the
bulk inserts a resubscription performs on tens of thousands of rows.

Verified on a 40 971-row database: migration to v27 completed at startup,
every row backfilled, and "prenom" and "AMELIE" now return the accented
titles on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
// performs on tens of thousands of rows.
@ColumnInfo(name = "title_normalized", defaultValue = "''")
@Exclude
var titleNormalized: String = title.normalizeForSearch()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

maybe normalized titles should not be stored into the database?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right on principle — it's a derived column, so it's duplication that has to be kept in sync. I went looking for the alternative and measured what it would cost.

FTS5 isn't available. Probed through the Android SQLite library itself (not the shell binary — they're compiled separately) on an Android 9 device:

sqlite_version              3.22.0
fts5 (any tokenizer)        no such module: fts5
fts4 + unicode61            OK

minSdk here is 26, and Room only offers @Fts4 — consistent with FTS5 not being guaranteed by the platform. The trigram tokenizer, which would have given substring matching, needs 3.34+ anyway.

FTS4 would change what search finds. Measured on a real 40 971-channel catalogue, searching nom:

anywhere in the title      74 hits    ← current behaviour
at a word boundary         31 hits    ← what FTS would give
lost                       43  (58 %)

nom would stop finding prénom, men would stop finding X-Men.

What the column costs, same catalogue: 0.96 MB out of a 57.41 MB database — 1.7 %, and queries stay around 6 ms including one returning 29 087 rows.

Doing it without a stored column isn't possible either: SQLite has no accent-folding function and Room can't register one, so it would mean loading all 40 971 titles into memory and filtering in Kotlin on every keystroke.

One thing I'll grant: on a catalogue ten times larger the LIKE '%…%' scan grows tenfold. No index can serve a leading wildcard, so that's true with or without the column.

The field sits outside the constructor so it can't go stale — a data class only copies constructor parameters, so copy(title = …) recomputes it.

If you'd still rather not carry the column, I'll take FTS4 and the narrower matching — your call, it's your codebase.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for the detailed analysis. Preserving arbitrary substring matching is important to me, so the stored normalized column makes sense under these constraints. My only remaining concern is the public mutable property on an @immutable model—could we avoid that?

@oxyroid

oxyroid commented Aug 9, 2026

Copy link
Copy Markdown
Owner

btw. I think you don’t need to handle the database migration in PRs. I’ll consolidate the schema changes and migrations when preparing the next release, to avoid conflicts between multiple PRs.

Two review points from @oxyroid.

The property is now a constructor parameter defaulting to
title.normalizeForSearch(), so the @immutable contract holds. It sat outside
the constructor to survive copy(title = …), which keeps the old value and would
silently drop a channel out of every search — but nothing in the codebase
copies a channel with a new title today, the two call sites in
PlaylistRepositoryImpl only reassign ids.

Migration, schema and migration test are out, to be consolidated with the other
PRs.
@Pierroons

Copy link
Copy Markdown
Contributor Author

Fair point — it's a constructor val now, defaulting to title.normalizeForSearch().

It sat outside the constructor to survive copy(title = …), which keeps the old value and would silently drop the channel out of every search. I checked before moving it: nothing copies a channel with a new title today — the two call sites in PlaylistRepositoryImpl only reassign ids. So the invariant holds without the mutable property.

Migration, schema file and migration test are out too.

One caveat worth flagging: with the entity carrying the new column but no version bump, a build rewrites the existing 26.json — new identityHash, and title_normalized appended to the v26 createSql. Room would then refuse to open an already-created v26 database. I've reverted it here, but it comes back on any clean build of this branch, so the column probably can't land without its version bump. Happy to put the migration back if that's easier for your consolidation.

@oxyroid oxyroid left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for your contribution! One small request: before asking for a review, please take some time to manually review the changes first.

We’re absolutely open to AI-assisted coding, but we don’t want to leave everything to AI. It’s important that contributors understand and stay in control of the code they introduce. It doesn’t have to be perfect, but obvious issues should ideally be caught before review.

stream.seen AS seen,
stream.relation_id AS relation_id
stream.relation_id AS relation_id,
-- Present only because Room requires every non-null field to be

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

don't leaves comments in SQL statement

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.

2 participants