Kapow

View the Project on GitHub sanchezpaco/kapow

Library

The home of the collection: import comics, browse covers, resume reading.

Import

When the library rescans

Sample comic

Persistence (Room)

KapowDatabase (core/storage) holds the library tables, exposed as Flows through LibraryRepository and combined into LibraryComic domain models for the UI. Per-comic reading settings live in their own table keyed by document Uri (below); page detections are documented in docs/ml-runtime.md.

Comic(
    id, documentUri, displayName,
    series, issueNumber, year,
    pageCount?, coverPath?, coverPage, coverAmbient?, addedAt, favorite,
    storyTitle?, publisher?, writer?, penciller?, inker?, colorist?,
    summary?, readingType?, metadataVersion,
    rating, metadataEdited,
)

ReadingState(
    comicId, pageIndex, completed, updatedAt, shelved,
)

ComicSettings(
    documentUri, readingType?, coverAlone, bubblesEnlarged?, guided?,
    bubbleScale?, splitWidePages, splitSuggested, verticalScroll,
)

ReadingSession(
    id, comicId, startedAt, endedAt, pages, mode, finished,
)

Bookmark(
    comicId, pageIndex, createdAt,
)

ReadingList(
    id, name, createdAt,
)

ReadingListEntry(
    listId, comicId, ordering,
)

Identity

Every table keys a comic by its documentUri (comics, comic_settings, page_detections; reading_states, reading_session and reading_list_entry hang off the comic id). A document Uri encodes the file’s path, so renaming or moving a file — or re-picking the folder after a lost grant — hands the scan a Uri it has never seen and would strand progress, favourites, per-comic settings, cached detections and the cover on a row whose file is gone.

The content hash is what actually identifies a comic, the same rule YACReader uses: SHA-1(first 512 KB of the file + the file size) (ComicIdentity.hash, pure; ComicHasher does the reading). The prefix keeps it cheap on a 70 MB archive, and the size makes two files that share an opening block distinct.

Relinking

documentUri stays the row key; the scan reconciles by hash instead (LibraryReconciler.reconcile, pure domain, given the arrivals and the rows whose file is gone):

Hashes are backfilled lazily by fillMissingDetails (the cover pass), which already walks every comic after a scan: a row with no hash gets one there, while its file is still where the library expects it. Nothing hashes a comic twice.

Grouping by series

Reading lists

An ordered, cross-series list — a crossover, a re-read, anything the file names cannot express. Lists are hand-ordered, never re-sorted.

Covers

Visual identity

The shelf uses a “Comic Red & Ink” palette local to the library UI: pure-black OLED ground, a comic-red accent (matching the reader theme’s primary) with an amber highlight and green for completed. Progress shows as a red ring on the cover corner (percent) and a red→amber bar under in-progress comics; finished comics get a green “Completed” badge and favorites an amber star. The most recently read unfinished comic becomes the hero: a wide card washed with the cover’s ambient colour, showing the cover, progress and an estimated time left (LibraryCatalog.minutesLeft, fed by the pace measured from the reader’s own sessions and falling back to 45 s per page — see docs/stats.md); any other unfinished comics follow in a row of smaller resume cards. Both carry a “×” that hides the comic from “Continue reading” without touching its progress (ReadingState.shelved = false); the next saveProgress upsert re-shelves it, so a comic reappears as soon as it is read again.

Home UI

Selection mode

Anything you can do to one comic you can do to a handful, because there is only one code path: the batch one.

Drag to select

The long-press that enters selection is the start of the drag; the finger never lifts. A long-press while already selecting re-anchors and starts a new one. A plain drag is never selection — in or out of the mode it is the grid’s scroll, which is the only way to still move around a 211-comic library with a selection live.

Search syntax

SearchQuery.parse turns the field’s text into a list of terms; every term must match (they are ANDed). Parsing and matching are pure and unit-tested (SearchQueryTest) — there is no Room LIKE query, because the library list is already in memory. Tokens split on whitespace, and double quotes keep a phrase together (writer:"al ewing").

A bare word matches, case- and accent-insensitively (NFD, combining marks stripped, so pena finds Peña), a substring of the title, series, story title, writer, penciller, inker, colorist, publisher or file name. The summary is deliberately not searched: it is prose, and every common word would match half the library.

A token with a recognised key: prefix narrows to one field. Keys have Spanish aliases, because the keys are the one piece of syntax a Spanish user has to guess; accents are folded, so año and ano are the same key.

term alias matches
series:hulk serie: the series
title:descent titulo: the ComicInfo story title
writer:ewing guionista: the writer
penciller:bennett dibujante: the penciller
inker:jose entintador: the inker
colorist:mounts color: the colorist
publisher:marvel editorial: the publisher
year:2018 año: the year exactly
year>2015, year>=2015, year<2000, year<=2000 año> year comparisons
rating:4+ valoración: rated at least N stars (+ is sugar for >=)
rating:3, rating>=4, rating<2 valoración> rating exactly, or compared; unrated is 0
added:30d — added within the last N days (d only)
is:reading — started and unfinished (pageIndex > 0 && !completed)
is:unread, is:read — accepted, but the filter chips already own them

Rules that keep the field forgiving — it never shows a syntax error:

Under the empty field sit three preset chips — In progress (is:reading), Highly rated (rating:4+) and Recently added (added:30d) — and one hint line. A preset types its literal query into the field, so the syntax is learned by seeing it, and resets the filter chip to All (LibraryViewModel.onPresetQuery) so a chip and a query can never contradict each other. Otherwise chip and query AND together. Presets and the hint render only while the field is blank and collapse with the same fade + shrinkVertically “Continue reading” uses, so the grid does not jump.

When a query is active and nothing matches, the grid area shows library_search_empty instead of the plain library_filter_empty.

Per-comic reading settings

Tapping a cover opens the reader directly; there is no intermediate screen. “Reading settings” in a card’s long-press menu opens ComicSettingsScreen for that comic. “Series settings” (stack long-press or the series header “⋮”) opens the same screen for the whole series (eyebrow “This series”, “Applies to all N comics”): the chosen values are written to every issue, and each issue can be tweaked individually from inside the series screen.

ComicSettingsViewModel observes the first target’s settings and fans writes out to all targets; it also exposes the global reading type for the “Default (…)” label.

KapowRoot derives a typed Screen (Library / Settings / CoverPicker / AppSettings / Reader) from its state slots and renders it inside SharedTransitionLayout + AnimatedContent. The cover picker keeps only the comic id in that slot and resolves the row on every composition, so the picker sees the coverPage it has just written. Screens cross-fade; the reader additionally scales in from 94 %. The cover is a shared element between the grid card (or series stack) and the settings header: Modifier.sharedCover(comicId) (SharedCover.kt) reads the transition and visibility scopes from composition locals, so a cover outside the animated root just renders normally. The reader receives the cover’s ambient colour as initialAmbient, so its backdrop already glows in the comic’s colour while the first page decodes instead of starting from the neutral default.

Metadata

What is read

ComicInfoParser (feature/library/domain) is a pure function over the XML text — DocumentBuilder, no IO, unit-tested — returning a ComicInfo. It reads Series, Number, Year, Title, Publisher, Writer, Penciller, Inker, Colorist, Summary and Manga, and ignores everything else (Volume, Count, Genre, Characters, Web, Notes, PageCount, Pages). A file that is not well-formed XML, or whose root is not <ComicInfo>, yields null and the comic simply keeps its filename metadata.

Precedence

mergeComicMetadata(info, parsed, edited) resolves the sources per field:

Field Winner
series the hand edit, else the XML when non-blank, else ComicNameParser
issueNumber the hand edit, else the XML when it parses to an Int, else ComicNameParser
storyTitle the hand edit, else the XML
year the XML when non-blank, else ComicNameParser
publisher, writer, penciller, inker, colorist, summary the XML only
readingType the XML only (Manga = YesAndRightToLeft → Manga, any other Manga value → Comic)

Hand edits

The Details section’s Edit details row opens a dialog over series, issueNumber and storyTitle (Series is required; Number is parsed leniently by parseEditedIssueNumber, so #012 and 12 are the same issue). Saving writes the three columns and sets metadataEdited, which is what EditedComicMetadata is built from: while it is set the metadata pass never overwrites those three, whatever METADATA_VERSION says, and a relink keeps them through a rename too — but publisher, the credits, summary and readingType keep improving from a better ComicInfo parse. displayName is never editable.

Reset to file (shown in the dialog only while the comic is edited) clears metadataEdited, sets metadataVersion back to 0 and re-reads that one comic, re-parsing its file name, so the values come back as a fresh scan would produce them. Downstream nothing knows an edit happened: the title, the hero, the cards and the series grouping are all recomputed from the edited columns.

The display title stays LibraryCatalog.title — "$series #$issueNumber" — so it silently becomes the real one. displayName always keeps the file name and is never overwritten: it is the row shown last in the Details section, so the user can always see what the metadata replaced. Every column here is searchable, by bare word or by key:value — see “Search syntax”.

When it is read

Not at scan time. The scan stays instant; the cover pass (fillMissingDetails) already opens every archive once, and reads the XML through that same ComicSource, so metadata lands a moment after the covers. METADATA_VERSION (next to the parser, same idea as DETECTIONS_VERSION in ml-runtime.md) is stored per comic: bumping the constant makes the next pass re-read every comic, which is also how a library that predates this feature gets enriched without a re-scan. It is at 2 since the reading type replaced the readsRightToLeft flag (schema v18).

Where it shows

Limits and follow-ups

Bilingual

All library strings come from resources (docs/i18n.md). Dates and numbers are formatted with locale-aware formatters, never hand-built strings.

App settings

The gear in the library header opens the app-wide settings screen (global reading defaults, screen, comics folder + rescan, theme, about). See docs/settings.md. “Choose folder” is also the primary action of the empty library.