* 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
* Add Apollo for GraphQL interactions
* Migrate AniList to use Apollo
* Migrate Shikimori to use Apollo
Not all Shikimori requests can be served by their GraphQL API (notably
they do not offer any mutations), so those remain as-is.
* Migrate Suwayomi to use Apollo
Includes leveraging some slightly newer Suwayomi features,
specifically the Double/Float filter fixes first pre-released in
2.1.1985 on 2025-10-26 and first fully released in 2.1.2100 on
2026-05-08.
Considering the tachiyomix 1.6 extension lib changes were added in
Suwayomi 2.3.2223 on 2026-06-30, I think it is fair to depend on such
sorta-recent behaviour.
* Fix temp migration names and leftovers
* Create ApolloResponse.dataOrElse extension
Because Apollo doesn't throw exceptions and instead just provides them
as response attributes, the current ApolloResponse.awaitSuccess() is
useless and will be removed shortly.
This new extension function should wrap the basic handling of an
ApolloResponse object neatly.
Co-authored-by: Mitchell Syer <6090655+Syer10@users.noreply.github.com>
* Use dataOrElse in AnilistApi.kt
* Use dataOrElse in ShikimoriApi.kt
* Use dataOrElse in SuwayomiApi.kt
* Delete obsolete ApolloCall.awaitSuccess
* Migrate Kitsu to use Apollo
Kitsu has some unfortunate edges in its GraphQL API (such as returning
nonstandard bodies which don't play well with Apollo) but this should
be an equivalent implementation.
* Extract a MangaFragment for AniList
This simplifies the queries and the converters (which can now delegate
to the MangaFragment.toTrackSearch() method internally and adjust the
track as needed).
Also move the toALUser converter to AnilistUtils.kt to keep all
converters together.
Added a scalar-mapper for AL's CountryCode type.
* Minor consistency cleanup of the queries
* Store list entry ID not title ID for Shikimori
* Extract common MangaFragment for Shikimori
Also map the ISO8601Date to a Kotlin String in the service config.
* Map Kitsu's ISO8601DateTime to kotlin.time.Instant
Utilises Apollo's own adapter-core library which provides the mapper.
Requires opting in to ExperimentalTime until the library resolves the
outdated annotation.
Co-authored-by: Mitchell Syer <6090655+Syer10@users.noreply.github.com>
* Replace Kitsu TODO comment
So much for STOPSHIP preventing me shipping this...
* Bump Apollo version to 5.1.0
* Bump apollo-adapters-core to 0.8.0
Freshly released just earlier.
This resolves having to opt in to ExperimentalTime.
* Make new utils internal to align with #3908
* Fix leftover issues from merged #3828
This PR restructured the AniList and Kitsu user objects.
* Bump apollo to 5.2.0
* Remove unused attributes from queries
* Allow custom exception handler in dataOrElse
Enables callers to manually handle specific non-200 responses.
Callers MUST close the `ApolloHttpException.body` attached to the
passed exception to avoid resource and socket leaks.
Defaults to current behaviour of (re)throwing the status code with our
HttpException.
Utilized in KitsuApi.kt to specially handle Kitsu sending a 500 error
response when deleting library IDs not actually in a user's library
and falling back to the "throw as HttpException" if it's any other
error response.
* Rethrow networking/IO exceptions
This helps to avoid masking networking problems as "This isn't in your
list" problems.
Currently, the app shows "Failed to connect to ..." when the remote
server is unreachable (e.g. downtime, DNS block, etc) because that's
the message provided by the ConnectException thrown by Android's
network stack.
This change restores that behaviour, approximately.
Not the most elegant solution but it _should_ avoid false-positive
toasts.
---------
Co-authored-by: Mitchell Syer <6090655+Syer10@users.noreply.github.com>
Extensions only reload when the set of signing keys changes, so one could keep a store that was removed while another with the same key stayed, or a store's old name. The manager now reassigns each installed extension's store whenever the stores change.
ExtensionApi only wrapped the store repository and the launch check, which matched updates again on its own. The manager now fetches listings and checks for updates itself with the statuses it already keeps, so the check covers not-loaded extensions and keeps the Extensions tab current from launch. Statuses are derived in one place, and the extension list is split before it's sorted instead of sorted twice.
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.
The reader builds its chapter list with excluded scanlators filtered out, so a chapter opened directly (from a deep link or a notification) whose scanlator is excluded wasn't in the list and failed to open. The requested chapter is now added back when the filter removed it.
Restore groups a backup's history entries by chapter before comparing them with the stored row, so a backup carrying one entry per copy of a duplicated chapter no longer adds its reading time more than once or moves the last read backwards. A removed history entry keeps its 0 instead of becoming NULL, a tracker's start date skips removed history, and the reader clears a session's start time before saving it, so overlapping saves can't count it twice.
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.
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 refresh button to tracker for new profile info
As-is, allows a user to manually refresh their current user data from
the tracker in order to update Mihon's knowledge of remote settings
(e.g. nickname changes, AL/MB/Kitsu score types, etc.).
* Catch errors to prevent app crashing
Surfaces errors as toasts in the same way library title refreshes do.
* spotless
* [skip ci] Remove leftover debugging logs
* Fix Kitsu user attribute access
* Spotless
* Lowercase Kitsu score type on refresh
Maybe prevent needing another #3887 for this.
* Center tracker vertically name if not logged in
Looks much better than the name being stuck at the top with empty
space below it.
* Don't show Refresh button for Enhanced trackers
* [skip ci] Add changelog entry
* Use & write Year, Month, Day info in ComicInfo
Currently, users can only modify the date Mihon displays in the
chapter list by manipulating the _last modified date_ of the file.
This is obviously inconvenient, especially since the ComicInfo spec
includes Year, Month, and Day fields for this exact purpose.
If present, this reads the date at start of day in the current default
system time zone (which matches how we use Chapter.dateUpload with
relativeDateText, for example).
If Year is not specified, the entire date process is skipped.
If Month is not specified, it is set to January.
If Day is not specified, it is set to the first.
The parsed date (if any) is set as the Chapter's `date_upload` which
is used for display in the app.
Additionally, Mihon now includes this info in the written
ComicInfo.xml file for downloaded chapters. This will be convenient
for many more users, not least of which those who move
source-downloaded chapters to Local Source to read from there.
---
I used kotlinx.datetime because it's what we use for date parsing in
non-i18n places already.
* Changelog
The previous setup had a circular dependency between Injekt
registration and extension loading.
In regard to #3931, I've moved the culprit ExtensionManager out of
Injekt altogether, alongside CoverCache, PreferenceStore and
TrackPreferences, none of which a source needs.
This might also fix the underlying problem of #3947.
I don't have any nonstandard devices available, but this works fine
in an A17 Pixel emulator & on my A14 Xiaomi device.
Looks like the GetContent approach was merely carried over from older
code without being updated to the more modern OpenDocument contract.
* Continuous background color
* Use ImageDecoder instead of Kim
* Only get tag when in dual mode
* Update libs.versions.toml
* Make progress circle smaller and animate progress
* Fix long strip showing the menu at ends
It gets really annoying to use any of them when they have name
collisions. Especially extensions on Track or basic public functions.
Also make some same-file extension function in the MangaUpdates
package method on the class they extended.
I would have done the same for the DTOs themselves but any that are
used on method signatures have to be public and then all their members
also have to be public. We'd end up with a confusing mess of some
internal and some public DTOs, even at the top level (since generics
can be internal to the `dto` package (e.g. with parseAs<...>) but
return annotations cannot be).