* Keep screens loaded while they're hidden and rebuild the library faster
* Count chapters in SQL for the migration list
* Show the extension list sooner and check trust against loaded stores
* Cancel the previous tracker search when searching again
* Write reader progress less often and keep its work off the main thread
* Stop rebuilding the settings search index on every key
* Watch updates for widgets only while one is placed
The query re-ran on every chapter or manga write even with no widget on
the home screen.
* Observe download pages and limit how often their progress updates
* Throttle the library update notification and write fetch data together
* Defer startup work until first use
* Move cover work off the main thread
* Make tracker calls main-safe and token refresh thread-safe
Kitsu, Shikimori and MangaBaka refresh their token under a lock like
MAL, so parallel calls no longer refresh it twice.
* Fix extension threading and make installs cancellable
The store service read response bodies on Main, which failed with
NetworkOnMainThreadException for http stores.
* Move reader work off the main thread and save progress in order
* Serialize the downloader and fix download threading
* Stop blocking on WorkManager status queries
Also removes NotificationReceiver's open chapter action: nothing sends
it, and its handler used runBlocking.
* Start migration jobs on the migrator's dispatcher
They started undispatched, so each ran on the caller's thread until its
first suspension.
* Run screen and view model work on fitting dispatchers
* Launch work that outlives a screen on the app scope
A launch on another dispatcher is dropped if the screen closes before it
starts, which dialogs that dismiss right away often do. Only the UI
follow-up stays on viewModelScope.
* Remove the coroutine helper extensions
They hid which dispatcher and scope a call used, and the GlobalScope
ones hid that it was global.
Replaces the eight ad hoc application lifetime scopes and the GlobalScope
launchUI/launchIO/launchNow helpers with a single injected
CoroutineScope(SupervisorJob() + Dispatchers.IO), qualified by
@AppCoroutineScope.
Every scope this replaces was already on IO, or named IO explicitly, so
call sites keep their dispatcher. The two that need the main thread use
scope.launchUI. Scopes with a real cancellation point keep their own:
HttpPageLoader, ShizukuInstaller and the reader viewers. Only
HttpPageLoader's page list cache save moves to the app scope, since it
runs after recycle() has cancelled the loader's own scope.
NotificationReceiver now holds the broadcast open with goAsync() while
its work runs, so the process isn't torn down mid update.
This reverts the revert of #3403. It was reverted for #3429, where
restore() dropped every download because SourceManager.get() returned
null until the extensions had loaded. That surface suspends since #3869,
so the race is gone.
eu.kanade.tachiyomi.data.database.models.Chapter predates SQLDelight. It
survived because it implements SChapter, so ReaderChapter could hand the
same object to the database and to the source.
ReaderChapter now holds the domain Chapter and converts at the three
points that actually need a source model: getPageList, getFormat and
getChapterUrl. The domain model is immutable, so the reader replaces the
chapter rather than writing through it, behind an update function so the
field cannot be reassigned from outside. Equality stays on the chapter it
was loaded with, which keeps the identity comparisons in the viewers
stable while progress is recorded against it.
updateChapterProgress no longer mutates the chapter and reads the values
straight back out to build the update it persists; it computes them once
and passes them. Marking a chapter read moves out of the function named
for completing it and into the same place the page is recorded.
Removing the interface also drops the nullable id it carried, along with
three non-null assertions and a Float to Double conversion that the
compiler had been warning about.
A page position is an index into a chapter's pages, so it cannot exceed
Int range, but SQLite has one integer type and the column came back as a
Long. That width reached the domain model and then every caller either
carried it or converted, and the viewers had to narrow it again to index
a list with.
Declaring the column INTEGER AS Int moves the narrowing into a column
adapter, so it happens once where the value stops being a database
number. No migration: SQLite still stores an INTEGER, only the type
SQLDelight generates changes.
Backups are the exception and keep converting, because the field is on
the wire as int64 and has to stay that way. That is also the only place
the value arrives from outside the app, where a malformed file could
carry something that does not fit and truncate to a negative index, so
it is clamped rather than cast.
Sync now recognizes the chapters of a manga together, which tells what a
name alone can't:
- Numbers every similar name shares, like the group in "Group 2 Chapter
386", aren't the chapter number.
- Chapters without a number get one from the chapters around them, as long
as none of them is in another volume, since numbers may start over in the
next one. Between two chapters they get the missing whole numbers when
those fit (2, ?, 4 -> 3), or .5, .6 and so on (10, ?, ?, 11 -> 10.5,
10.6). Bonus chapters after the newest chapter go above it. A prologue
gets 0, and volume 0 when the chapter after it has a volume.
Recognition returns a ChapterNumbering of the volume and chapter number as
strings, null when there is none. Sub-chapters keep their letter (19a) and
parts become one (Chapter 1.5 Part I -> 1.5a); sync turns the chapter
number into a double the way letters always counted. Volumes, seasons
included, are recognized from names and are no longer taken as the chapter
number. Sync only uses the chapter number for now.
* query - category
Key every single-row statement on :id and list columns on one line. A
lookup by primary key no longer needs LIMIT 1, and resetting every
category's flags takes a value rather than a nullable nobody passed.
Assisted-by: Claude:claude-opus-5
* query - chapter
List columns in the table's order and name bind and mapper parameters
after the columns they fill. Inserts pass their arguments by name,
since reordering the columns put the upload and fetch dates side by
side with the same type.
With (manga_id, remote_url) unique, looking a chapter up by them no
longer needs LIMIT 1. Available scanlators come back distinct instead of
one row per chapter, which is all the only caller wanted. insert was
insertReturningId without the RETURNING, so restore uses the latter and
the former goes, along with the unused getChapterByUrl.
Inserting skips a chapter that a sync running alongside stored first,
rather than failing the whole batch and losing every new chapter of
that update.
Assisted-by: Claude:claude-opus-5
* query - excluded_scanlator
An entry excludes a scanlator at most once now. Inserting one that is
already excluded does nothing, so restoring a backup that lists one twice
drops the repeat instead of failing the entry, and restore no longer
reads the stored exclusions first to skip them itself.
Saving an unchanged selection no longer runs an empty delete, which
still made every chapter query run again.
Assisted-by: Claude:claude-opus-5
* query - extension_store
Take the upsert's new values from excluded and drop a WHERE its
conflict target already implies. That also retires a
`signing_key =: signingKey` that only parsed by accident.
Assisted-by: Claude:claude-opus-5
* query - history
Restoring an entry's history looked up the history row and, failing
that, the chapter for every item in the backup, one query each. It now
reads the entry's chapters and history once and matches them in memory,
which leaves getHistoryByChapterUrlAndMangaId without callers.
Creating a backup looked up the chapter of every history row one at a
time and failed on a row whose chapter was gone. It reads the entry's
chapters once and leaves such a row out.
The upsert takes its new values from excluded, drops a trailing WHERE
its conflict target already pins, and names its parameters after the
columns they fill.
Assisted-by: Claude:claude-opus-5
* query - manga
List columns in the table's order and name bind and mapper parameters
after the columns they fill, forwarding mapper arguments by name.
With (source_id, remote_url) unique, looking an entry up by them no
longer needs LIMIT 1, and fetching a network entry becomes a single
upsert that refreshes an existing row only while it is outside the
library, instead of an insert guarded by NOT EXISTS followed by an
update of the same row.
"Is anything read" checks use EXISTS rather than IN over a DISTINCT
subquery, category membership drops a coalesce on a column that is
never null, and the unused getAllManga goes. Applying the default
chapter flags to the library is one transaction instead of one per
entry, and restore checks backup entries against the stored urls as a
set rather than a list.
Assisted-by: Claude:claude-opus-5
* query - manga_category
An entry belongs to a category at most once now. Inserting a pairing
that already exists does nothing, so setting an entry's categories from
a list that names one twice no longer fails, which restore could produce
when two backup categories resolve to the same local one.
Assisted-by: Claude:claude-opus-5
* query - manga_track
List columns in the table's order and name bind and mapper parameters
after the columns they fill. That retires the sync-era :syncId,
:mediaId, :totalChapter and :trackingUrl.
Assisted-by: Claude:claude-opus-5
* query - source
List columns in the table's order and name the upsert's parameters
after its columns. The upsert takes the conflicting row's values from
excluded instead of rebinding them, and drops a WHERE its conflict
target already implies. The call passes its two string arguments by
name, since swapping them would still compile.
Assisted-by: Claude:claude-opus-5
* query - historyView
The latest read entry is read from the tables through the index on history's read time instead
of aggregating the whole view for one row: ~50ms to under 1ms on a 114k chapter library.
Assisted-by: Claude:claude-opus-5
* query - libraryView
List the columns like the other queries.
Assisted-by: Claude:claude-opus-5
* query - updatesView
Drop a coalesce on category_id, which is never null, from the queries
over updatesView, and the unused getRecentUpdates. The view definitions
themselves are unchanged, so no migration is needed.
The read and unread lookups become separate queries with the status
written into each. SQLite only uses a partial index when the query's own
condition matches it, which a bound parameter never does, so the unread
lookup the widget follows walked nearly the whole fetch date index,
~60ms each time on a 114k chapter library and after every chapter or
history write. With the status written in, it's served by the partial
index over unread chapters, ~8ms.
Assisted-by: Claude:claude-opus-5
* Query browse entries only while they are shown
Each loaded entry kept a live query for as long as the screen existed,
so every write to manga re-ran all of them. An entry now starts from the
row the listing stored and queries only while shown.
Assisted-by: Claude:claude-opus-5
* Read library selection categories from memory
The change category dialog and category updates read each selected
entry's categories from the loaded library instead of querying them one
at a time.
Assisted-by: Claude:claude-opus-5
Extensions that aren't loaded now get updates like loaded ones, which is often what gets them loaded again. They're counted, notified and listed with the other pending updates. Every installed extension, loaded or not, records the added store whose key signed it when it loads.
Updates come only from stores whose key the installed extension is signed with, the highest version among them. A downloaded apk must be signed with its store's key before it's installed.
Upserting a track for an entry and tracker that already have one updates that row, instead of the table's REPLACE deleting it and inserting a new one under a new id. Anything keyed to the old id, such as a queued tracking update, keeps working. The query, repository methods and interactor are renamed from insert to upsert to say so.
A new category's position is computed by its insert, one after the highest, so two categories created at once can't take the same one. Inserts take a NewCategory rather than a Category whose id and position would be ignored. Reordering places any category missing from the given order after the rest instead of leaving it at a stale position. Restore still creates missing categories all or none, in backup order, and the create dialog no longer accepts an empty name.
Stub sources loaded from the database were written into a throwaway copy of the map, so they never reached the source manager. StubSource also becomes a data class, so a stored one compares equal to the same source again and isn't rewritten on every reload.
A chapter sync's removals, inserts and updates go through one ChapterRepository.updateFromRemote call, applied atomically. A failure no longer leaves some of a sync's changes written and others missing. Restore folds duplicate chapters in a backup into one before writing, and the chapter deep link looks up the synced chapter afterwards rather than relying on the sync's return value.
Reordering first moves every category to a temporary negative position, then assigns the final ones, so two categories never share a position mid-update. Deleting a category no longer renumbers the system category.
Migration 16 replaces favorite, date_added and favorite_modified_at with one nullable favorite_at. A favorite whose date_added is 0 falls back to favorite_modified_at. Manga.favorite and dateAdded become getters derived from it, and restore merges it by taking the earliest non-zero time.
app no longer touches the database directly. Backup restore's queries move into a new RestoreRepository in :data, which restores a batch of entries all or nothing, stopping between entries if cancelled, as before. The remaining direct calls (excluded scanlators, clear database, backup creation, store and category restore) go through their repositories, and the database bindings move from AppBindings into :data.
Source-owned fields move out of MangaUpdate/ChapterUpdate into MangaRemoteUpdate/ChapterRemoteUpdate, which only UpdateMangaFromRemote and SyncChaptersWithSource write. Backup restore gets its own updateFromBackup queries for entries and chapters, writing only what a backup carries.
* Add category filters for Updates tab
Works just like Library Updates.
Provides a tab in the Updates tab filter dialog to allow for including
& excluding of updates belonging to a title from a given category.
Excludes overpower Includes, as with Library Updates.
A little helpful text informs users about this.
* Changelog
* [skip ci] Remove unused string
Leftover from before I realized the Default category will always be
available for in-/exclusion.
* Make :includeEmpty/:excludeEmpty true Boolean params
* "include" -> "included"
Consistency with #3607.
* Remove DISTINCT from getRecentUpdatesWithFilters
Apparently not needed anymore (though it was required at some point).
* [skip ci] Fix Changelog
* Replace java.time APIs with kotlin(x).(date)time
kotlinx-datetime explicitly says in its README that it does not cover
i18n, so we have to stick with using their toJavaX converters wherever
we localise dates and times.
Yes, some of these replacements are... questionable. But I wanted to
try and maximise the replacement for now.
* Bump kotlinx-datetime to 0.8.0
* More kotlin.time/kotlinx-datetime replacements
* Remove redundant init block & make attributes val
* Replace new use of java.time.Instant
* Rework library search to use an AST-based approach
* Spotless apply
* Move the filtering logic to the database
* Spotless apply
* Cleanup and separation of concerns
* Add `notes` to field and general search, and chapter read, total chapters to comparison search
* Handle searching for empty fields
* Add `language` as a field-only query option
* Clean up SQL clause generations
* Spotless apply
* Do not parse `-` as a negation when followed by a separator
* Revert moving filtering logic to database
* Changelog
* Use contains for genre instead of equals
* Add special handling for source name
* Add field-only query for source id
* Apply suggestions