ottershelf

Ottershelf: architecture

Ottershelf is a native Android client for self-hosted BookOrbit servers (/api/v1), for phones running Android 12+ (minSdk 31, targetSdk 36, compileSdk 37). The layouts are designed for a phone screen of about 411x891 dp. The app has been tested on a Pixel 8 Pro running GrapheneOS and a Nothing CMF Phone 2 Pro.

It grew out of an earlier BookOrbit client written for the Nexus 7 tablet, called “the Nexus app” in this guide and in code comments: many screens keep that app’s layout, and the data layer is ported from it with the logic unchanged. The compromises that app made for the tablet’s old Android version are dropped.

This file is the developer guide: how the code is organised, what each part does, and the rules for changing it.

Package layout (io.github.ottershelf)

Package What it holds Owner
App.kt OttershelfApp (the Application and the Coil ImageLoader), AppContainer (DI), Context.appContainer data layer
MainActivity.kt The only activity: splash screen (held until the saved appearance is read), enableEdgeToEdge(), setContent { OttershelfTheme(prefs = container.themePrefs) { AppRoot() } }, sync on resume shell
core.model Models.kt, RequestModels.kt: @Serializable API types and request bodies. Pure Kotlin. data layer
core.network Api (REST client and auth), ApiException, ApiJson (the one Json config), Connectivity data layer
core.session Session (server, user, encrypted tokens), TokenCipher / KeystoreTokenCipher, AuthState (signed in or out, current user) data layer
core.sync ProgressStore (positions, sessions, conflicts), ProgressRemote, SyncCoordinator, SyncScheduler + SyncWorker (WorkManager), ReadingChanges, PageProgress + PageReaderProgress (the page-based readers’ positions and sessions, see “Readers”), ReadingVisit (a reader opening’s sessions: none sent, and no position saved, until the user moves) data layer
core.format BookFormats (which reader opens a format, which file a book opens and keeps offline), ReaderKind (see “Readers”) data layer
core.readerprefs Reader settings on the device and the account: ReaderSettingsStore, ReaderSettingsSpecs (CbxReaderSettings, PdfReaderSettings), ReaderPrefsRemote / ApiReaderPrefsRemote (see “Readers”) data layer
core.download Downloads, DownloadWorker, DownloadNotifications, LocalEpub, DownloadedBook, DownloadEvent, OfflineFiles / OfflineCopy (any format kept offline, see “Readers”) data layer
core.settings settingsDataStore: the single Preferences DataStore; AppSettings (the app’s users.settings key bookorbitAndroid, versioned), AppSettingsRepository (read from auth/me, written whole, offline-safe), JsonMerge data layer
core.tracking The reading tracker (see “Tracking”): TrackingRepository, TrackingRemote / ApiTrackingRemote, TrackingModels.kt, TrackingQueue, TrackingScheduler + TrackingWorker, PageMath, TimerEngine (+ ActiveTimer, FinishedTimer, TimerState), TimedParts (+ ActiveSpan: a timed session split by day), TimerNotifications / TimerIntents / TimerActionReceiver / TimerRestoreReceiver data layer
core.theme ThemePrefs (the six web appearance settings and their ids: ThemeMode, Accent, Radius, ThemeBackground), ThemeRepository (device copy + server sync), ThemeRemote and its request bodies theme
core.util IsoTime (java.time) and other pure helpers data layer
ui.nav Route / Chrome (+ Route.sourceKey / .query), AppNavigator, AppNavigatorState / SearchBarState, AppRoot / AppScaffold (drawer + NavDisplay), AppDrawer.kt (AppDrawerSheet, DrawerEntry, DrawerAction, compactCount), RootFrame.kt (RootFrame, RootTopBar, TopBarActions), NavGraph.kt, appViewModel / LocalAppContainer, ShellViewModel / DrawerUiState, PendingRoute (a screen asked for by a notification) shell
ui.theme OttershelfTheme (+ OttershelfTheme.colors / .radii / .prefs), OttershelfColors and ottershelfColors() (the web tokens), oklch(), OttershelfRadii, OttershelfTypography / OttershelfFonts, BackgroundPainter / BackgroundPatterns / Modifier.backgroundPattern / PatternBackground; patterns/AppearancePatterns.kt holds the patterns beyond none and dots theme (patterns file: appearance work)
ui.components Shared composables: BookCover, BookCoverPlaceholder, BookGridItem, ShelfCover, badges (StatusBadge, StatusIcon, FormatBadge, FormatChip, SeriesBadge, CountPill), PillProgressBar, CoverProgressBar, DashCard, SectionHeader, CardTitle, CardRow, EmptyState, ErrorState, LoadingState, SkeletonBox, AccentButton, SecondaryButton; and the shell’s DetailScaffold, PlaceholderContent, rememberNotificationPermissionRequest; the readers’ ImmersiveSystemBars, and KeepScreenOn / KeepScreenOnWhileReading (readers, timer) theme + shell (additions welcome, see below)
ui.icons Lucide (the whole catalogue in assets/lucide.txt, by PascalCase name), LucideIcon(name, ...), LUCIDE_BUILT_IN (generated by tools/gen-lucide-builtin.sh), AppIcons (ready-made vectors for the shell) icons
feature.login LoginScreen, LoginViewModel (Nexus LoginActivity: the server field starts blank, no server is built into the app, and shows the stored one after a sign-in; https only, AppContainer.signIn, errors inline; the Ottershelf badge and wordmark on the pattern, in the web login’s layout), ServerAddress (the typed address tidied: lower-case host, no port 443, no trailing slash; a user, query or fragment refused; the stored spelling kept for the same server, so the account key, and with it the user’s downloads and queued progress, never depends on how it was typed; ServerAddressTest) login
feature.home HomeScreen, HomeViewModel (the Dashboard: the Nexus DashboardFragment’s freshness rules behind a DashboardRemote, tested in HomeViewModelTest; reports each load to AppNavigator.dashboardLoaded), DashboardRemote.kt (ApiDashboardRemote, ShelfPrefs), WidgetCards.kt, WidgetCharts.kt, ShelfCards.kt, CustomiseSheet.kt, Reorder.kt (ReorderState: drag to reorder in a LazyColumn), model/ (DashboardModels.kt, DashboardOrder.kt, CustomiseDraft.kt: pure, DashboardModelsTest, DashboardOrderTest); see “The Dashboard” home
feature.library BookListScreen (a source’s books: a root list, or an author’s/series’ books pushed), AuthorsScreen, SeriesScreen and their ViewModels; Pager / PagedState (the Nexus paging: 60 per page, pull to refresh, repeated items skipped, restart() for a new sort); sorting and filtering (see “Sorting and filtering the book grids”): LibrarySort.kt (SortField, ListKind, ListSort, querySegments, the query bodies: pure, LibrarySortTest), BookListLoader + BookListRemote / ApiBookListRemote (BookListLoaderTest), LibrarySortPrefs, LibrarySortSheet.kt (SortButton, SortChips, SortSheet / SortSheetContent), SortableList; the views (see “Grid, list and the quick view”): LibraryView.kt (ListView, ViewMode, GridColumns, PinchSteps, anchoredTop, cellHeightAt: pure, LibraryViewTest), LibraryViewPrefs (LibraryViewPrefsTest), LibraryGrid.kt (BookGridFrame + the pinch, BookRow, ViewMenuButton / ViewMenuContent, GridMetrics), QuickView.kt (BookQuickViewSheet, BookQuickViewContent) + QuickViewModel.kt (BookQuickViewModel, QuickViewUiState); LibrarySelection.kt (a book grid’s Select, the toolbar’s tick icon: taps and long presses pick books, a SelectMark on each, and SelectionBar floats over the grid with the count, Select all (the books loaded), Add to collection (CollectionPrompt.add, then the selection ends) and Done; Back ends it too; the picked ids are saved with the screen); BookListEdits (books edited on this phone, see “Editing a book’s details”) library
feature.book BookDetailScreen, BookDetailViewModel, CoverTint (the web’s cover tint), BookPreview (the tapped grid card, so the page shows title and thumbnail at once); the tracking part (see “The book page’s tracking”): BookTracker (owned by the ViewModel), BookTracking.kt (BookTrackingUiState, progressOf, timelineOf), BookTrackingSection.kt (progress card, rating and review, reading log), TrackingDialogs.kt, FinishCelebration.kt; PageCounts (an ebook whose metadata has no page count, and no total the user set, is looked up once per account when its page loads: GET metadata-fetch/stream (SSE, the web’s metadata search), a candidate with the book’s ISBN, else the median of those with the same title and author’s surname, 10 to 5000 pages; saved with PATCH books/:id/metadata {pageCount} when the account may edit details and the field isn’t locked, else as the user’s own AppSettings.pageTotals; the details line shows either; PageCountsTest) book
feature.collections AddToCollectionHost (in the shell) + AddToCollectionViewModel + AddToCollectionContent: a sheet of the user’s own collections for a CollectionTarget (one book or several), opened by a book just marked Want to read (the book page, the quick view, the Dashboard’s queue: CollectionPrompt.offer), the book page’s toolbar button and the quick view’s “Add to collection” (CollectionPrompt.add), and a library grid’s selection (feature.library SelectionBar). POST collections/membership {bookIds} says how many of the books each holds: ticked for all, a dash for some (Membership); a tap on a tick takes them all out, otherwise adds them all (POST/DELETE collections/:id/books {bookIds}; the server skips those already in; shown at once, put back if refused); “New collection” makes one (POST collections {name, icon: FolderOpen}) with the books in it; the drawer reloads after a change. Dismiss to skip. CollectionsRemote, AddToCollectionViewModelTest collections
feature.reader ReaderScreen (the WebView in an AndroidView, immersive mode, activity_reader.xml’s bars in Compose), ReaderChrome.kt (ReaderContent, the footer, the TOC sheet, the two-positions dialog), ReaderSettings.kt (the settings sheet, SpacingSteps), ReaderColorPicker.kt (the custom page colour dialog), ReaderStyles (settings to the page’s CSS and layout, PageColor: pure, ReaderStylesTest), ReadingTime (the footer’s time left, ReadingTimeTest), ReaderPaceStore (see “The foliate reader’s layout”), ReaderViewModel (the Nexus ReaderActivity’s open/save/push/session/conflict logic and the window.Android bridge; the final save runs from onCleared in the app scope), ReaderRequests (answers only this book’s epub/<id>/info and epub/<id>/file/... for an EPUB, or books/files/<fileId>/serve for the formats read whole: downloaded copy first (LocalBook), else the server; anything else 403), ReaderPrefsStore (DataStore reader.prefs); annotations/: highlights, notes, bookmarks and in-book search (see “Reader annotations”) reader
feature.comics The comics reader (see “Readers”, “The comics reader”): ComicsScreen (Comics(bookId, fileId, title): immersive, the settings sheet, the two-positions dialog), ComicsViewModel (opening, pages, progress, settings, the next comic), ComicsChrome.kt (ComicsContent, bars, NextIssueCard), ComicsPages.kt (the pager of pages or spreads with Telephoto zoom, the vertical strip, tap zones, PageTurner), ComicsSettings.kt, ComicPages.kt (ComicSource: server or downloaded CBZ; LocalCbz page order; LocalPageFetcher; ComicImages, the reader’s own Coil loader), SpreadLayout (the web’s spread rules) comics
feature.pdf The PDF reader (Pdf(bookId, fileId, title, page?); see “The PDF reader”): PdfScreen (immersive, sheets, password prompt), PdfViewModel, PdfEngine (platform PdfRenderer on its own thread) + PageBitmapCache + EnginePageSource, PdfViewer.kt (ContinuousPages, PagedPages, readerGestures), PdfChrome.kt (PdfContent, bars, contents/search/settings sheets, PdfPasswordDialog), PdfFiles (downloaded copy, or serve into the cache), PdfOutline (the document outline reader), PdfModels.kt (PdfUiState, PdfSettings, PdfMath: pure, PdfMathTest, PdfOutlineTest) pdf
feature.downloads DownloadsScreen, DownloadsViewModel (the drawer’s Downloaded: the book grid of Downloads.list() with local positions, downloads on their way first with WorkManager progress, feature.library’s views (grid or list, pinch, the view menu; kept as library.view.<account>.downloaded) and its quick view on a long press (Read, Book page, Remove download asked first, status); fully offline) downloads
feature.requests RequestsScreen, RequestsViewModel (Nexus RequestsActivity: metadata search and request, My requests polled every 5 s while something moves, a detail bottom sheet with the web’s pipeline; RequestPrefill: opened as RequestBook(isbn, title?, author?) from the scanner, the search already run for that ISBN) requests
feature.scan ISBN barcode scanning (see “Scanning ISBNs”): ScanScreen (Scan(forTimer): permission, camera, navigation) + ScanViewModel (lookup, chooser, not-in-library, typed ISBN), ScanContent.kt (the overlay, reticle, permission panels and the sheets), BarcodeCamera (CameraX ImageAnalysis + zxing-cpp, EAN-13 only), Isbn / IsbnInput (pure: barcode and typed parsing, a frame’s first ISBN, check digits, 10/13 conversion), ScanQuery.kt (pure: the isbn rule query, the title query, bestCandidate, sameWork, ScanCard), ScanRemote / ApiScanRemote, ScanEntry.kt (ScanForTimerAction, the Currently Reading card’s “Scan a book”); IsbnTest, ScanQueryTest, ScanViewModelTest scan
feature.settings SettingsScreen (account, Appearance, sign-out via AppContainer.signOut, version), AppearanceScreen (the web’s Display > Theme page: storage mode, colour scheme, 64 accents, radius, surface opacity, dark brightness, pattern gallery; every change live through ThemeRepository), their ViewModels, SettingsParts.kt (the web’s .settings-* card pieces) settings
feature.timer TimerScreen + TimerViewModel (Timer(bookId): count up or countdown picked before starting, the time large in a DashCard with the big pause/resume button, Cancel (confirmed) and Save, keep screen on (KeepScreenOn), the book strip “p. 130 / 464”; Save pauses and opens SaveSessionSheet), TimerResultScreen + TimerResultViewModel (TimerResult(bookId, sessionId): the saved session’s SessionSummary, or the stopped timer’s save form when opened from the notification), TimerSessions (book pages, save = stop + logTimedSession + clearFinished, remembers an edited page total and the unit), TimerModels.kt (TimerBook, SaveForm, ProgressInput, SessionSummary: pure, TimerModelsTest), TimerBar.kt (RunningTimerViewModel, the Dashboard’s RunningTimerBar, the reader’s ReaderTimerNotice) timer
feature.calendar CalendarScreen + CalendarViewModel (root, the drawer’s Tracking > Calendar: the Book Calendar month grid with covers and the readings’ marks, CalendarCache per month on the device, MonthImage saves the month card to Pictures/Ottershelf, CalendarToolbar), DayScreen + DayViewModel (Day(date)), ReadingGoalsScreen + ReadingGoalsViewModel (from the calendar’s toolbar and Settings), CalendarModels.kt (CalendarMonth/CalendarDay/CalendarBook, CalendarMark/MarkKind/DayStack, CalendarMath: pure, CalendarModelsTest, CalendarMarksTest), CalendarParts.kt (GoalRing, StatTile, durationLabel); a book’s start and end dates (see “Reading dates”): ReadingDates.kt (DatesTarget, DatesForm, DatesPlan, ReadingDates: pure, ReadingDatesTest), ReadingDatesRemote.kt (PickerBook, PickerQueries, ReadingDatesRemote / ApiReadingDatesRemote), ReadingDatesViewModel (+ readingDatesViewModel(); ReadingDatesViewModelTest), ReadingDatesSheet.kt (ReadingDatesHost, BookPickerContent, ReadingDatesContent) calendar
feature.history HistoryScreen + HistoryViewModel (root, the drawer’s Tracking > History, beside the Calendar: every reading of every book with its dates; see “The reading history”), HistoryRemote / ApiHistoryRemote (the book query and each book’s reading-attempts), HistoryLoader (pages, then readings six at a time, only for changed books), HistoryCache (historyCacheFile), ReadingLog + SavedReadings (the same data for the Calendar’s and Day’s marks, with the readings saved on this device on top), HistoryModels.kt (HistoryCard, HistoryEntry, HistoryGroup, HistoryFilter, HistoryMath: pure, HistoryMathTest) history
feature.stats StatsScreen + StatsViewModel (root, the drawer’s Tracking > Statistics: Reading and Library tabs; every chart loads on its own), StatsRemote / ApiStatsRemote (user-statistics/* and statistics/* GETs over Api.send), model/StatsModels.kt, StatsMath (pure: buckets, heatmap year, ticks, funnel, period changes (Change: a percentage, or “up from 18 s” on a tiny base), the year’s finished books from completion-timeline (withFinishedBooks: the overview’s own count can come from sessions); StatsMathTest), ChartPalette (the dataviz colour rules and validator math; ChartPaletteTest runs the validator on every accent), Charts.kt (Canvas column, line, scatter, heatmap and ranked-bar charts with a tap/drag readout), StatsChartCards.kt, LibraryStats.kt, StatsParts.kt stats
feature.achievements AchievementsScreen + AchievementsViewModel (root, the drawer’s Tracking > Achievements: GET achievements as Nexus sections per category with a ring and earned/total, a two-column grid of badge cards, filter All / Earned / In progress, a bottom sheet per badge with its tiers and context book), AchievementLogic (tiers grouped as the web does, kebab icon names to Lucide, filters; AchievementLogicTest), RarityPalette (validated rarity colours), Celebration.kt (CelebrationViewModel + AchievementCelebrationHost: the unlock toast in the shell), RewindScreen + RewindViewModel + RewindMath (the year in review; RewindMathTest), AchievementsRemote / ApiAchievementsRemote, model/AchievementModels.kt achievements
feature.notes Highlights and notes (see “Highlights and notes”): NotesScreen + NotesViewModel (root, the drawer’s Tracking > Notes), BookHighlightsScreen + BookHighlightsViewModel (BookHighlights(bookId, title)), BookHighlightsSection (the book page’s card, its own BookHighlightsSummaryViewModel), MemorizeScreen (+ MemorizeViewModel), QuoteCard.kt (QuoteCardDialog: the share-as-image card, CoverArt, CoverBlur), NotesShare (FileProvider share, MediaStore save), NotesRemote / ApiNotesRemote + NotesRepository + RandomNotes + NoteChanges, NotesLogic (pure; NotesLogicTest), NoteParts.kt (cards, colour bar, style panel, note editor), model/NoteModels.kt notes
feature.quotes Quotes typed or photographed (see “Quotes”): AddQuoteScreen (AddQuote(bookId?, title?, bookFixed, scan): the form, BookPickerSheet, the camera permission dialogs) + AddQuoteViewModel (form, picker, scan steps, save), ScanContent.kt (CameraX viewfinder and shutter, reading, the line picker over the photo), PageReader (decode upright, Tesseract through Tesseract4Android), Tessdata (the bundled English model, copied out for Tesseract), OcrLayout + TessLine (pure: Tesseract’s lines as blocks and lines; OcrLayoutTest), QuoteCleanup + OcrLine/OcrPage (pure: hyphenation, line breaks), QuotePosition (the placeholder CFI, page labels), QuotesRemote / ApiQuotesRemote + CreateQuoteBody, QuotePhotos (kept source photos) + QuotePhotoDialog (shows one), QuoteEntry.kt (QuoteButtons for the book page, AddQuoteAction for the Notes toolbar); QuoteLogicTest quotes
feature.bookedit Editing a book’s title, authors, series, number and cover (see “Editing a book’s details”): BookEditScreen (BookEdit(bookId), from the book page’s pencil) + BookEditContent (stateless), BookEditViewModel (form, save, unlock, cover changes; canEditDetails), BookEditModels.kt (EditForm, MetadataChanges (the PATCH body), problemsOf, EditRules, LockGroup, EditError: pure, BookEditModelsTest), Suggestions.kt (SuggestionSearch: debounced, stale answers dropped; authorRows, seriesRows: pure, SuggestionsTest), BookEditRemote / ApiBookEditRemote, CoverImage.kt (CoverImage downscale and JPEG, CoverScale, CameraCrop: CoverImageTest), CoverCamera.kt (CameraX with a 2:3 guide), CoverSearch.kt (the online search sheet), BookEditDialogs.kt, BookEdits.kt (BookEditPublisher, CoverCache: EditedBooksTest; the lists: BookListEditsTest); BookEditViewModelTest, BookEditScreenshotTest bookedit
feature.seriesnext The next book in a series (see “Next in series”): SeriesNext (pure: SeriesNumber, pick, the reader’s end fractions; SeriesNextTest), SeriesNextLoader + SeriesNextRemote / ApiSeriesNextRemote (SeriesNextState, NextBook; SeriesNextLoaderTest), SeriesNextViewModel (+ rememberSeriesNext for the book page, rememberSeriesNextAfterSession for the timer’s result), SeriesNextCards.kt (NextInSeriesCard, NextInSeriesRow, the reader’s EndOfBookCard / EndOfBookCardContent) series next

Assets (app/src/main/assets) come from the Nexus app. foliate/ is vendored foliate-js with the Nexus patches (all kept: the bug fixes and the performance ones), plus foliate/vendor/ (fflate.js for MOBI/KF8, zip.js for zipped formats: unmodified from BookOrbit’s client/public/assets/foliate); reader/ is the reader page and bridge, and lucide.txt holds 1838 icons. reader/ is adapted for the phone: the Chromium 100 polyfills are gone; it applies what the app computes from the reading settings (ReaderStyles.pageSettings: the chapters’ CSS, the page colours, the paginator’s attributes, the fixed-layout spreads; see “The foliate reader’s layout”); it reads the formats other than EPUB whole (see “Readers”); and it pushes the full location to the app shortly after every settle and on visibilitychange/pagehide, so closing the reader never waits for the WebView. tessdata/eng.traineddata is Tesseract’s English model (tessdata_fast, unmodified; its commit and hash are in THIRD_PARTY_NOTICES.md), for the quotes’ page reading (see “Quotes”). MainActivity handles rotation itself (configChanges), so the reader keeps its WebView.

Data layer

The data layer is ported from the Nexus app’s data/ package and BookOrbitApp.kt. The logic and the KDoc explanations are kept. Only these things changed:

Api

Session

AuthState is new and replaces the Nexus signedOut flag and app.user:

ProgressStore

Sync (SyncCoordinator, SyncScheduler, SyncWorker)

Connectivity

ReadingChanges holds the Nexus readingChanges, statusOverrides and dashboardClearedFor:

Downloads

LocalEpub is unchanged, apart from a pure percentDecode in place of android.net.Uri.decode. It now runs on a plain JVM.

Coil 3 (OttershelfApp.newImageLoader):

ThemeRepository (core.theme, AppContainer.theme; AppContainer.themePrefs is its StateFlow<ThemePrefs>) keeps the six web appearance settings exactly as the web client does (useThemeSync.ts, AppearancePreferenceStorage.vue):

Rules for the data layer

Tracking

Bookmory-style reading tracking on top of BookOrbit’s own data. Decisions: paper reading is logged against the library’s copy of the book; the server’s midnight day split (in users.settings.timezone) is kept; app-only data lives in one users.settings key; no Bookmory import; highlights, notes and quotes are their own features (see “Highlights and notes” and “Quotes”). Everything is in AppContainer:

Object What it is
container.tracking TrackingRepository: sessions, status, readings, rating, review, statistics, achievements
container.appSettings AppSettingsRepository: the app’s own key (AppSettings)
container.timer TimerEngine: the reading timer and its notification
core.tracking.PageMath pages, Day N, reading number, pace, time left (pure)

App settings (core.settings.AppSettings, users.settings bookorbitAndroid). Edition page totals (pageTotals), hand-set current pages (pageOverrides, with the time they were set), progress units, the daily goal in minutes, kept quote photos (quotePhotos), timer defaults (TimerPrefs: mode, countdown minutes, keep screen on), the Dashboard’s order of cards (dashboardOrder, see “The Dashboard”). Maps are keyed by book id. v is the schema version: adding a field with a default needs nothing; reinterpreting one bumps VERSION and adds a step to migrate. Keys a newer app wrote are kept when this one writes. Features may add small fields (a shared-file edit).

TrackingRepository (every body checked against the server DTOs):

PageMath. pageTotal(book, settings) (the total of the user’s edition, else pageCount), percentToPage / pageToPercent (round-trip exact), currentPercent(latestEndProgress, fileProgress) (the higher, as the web shows it), startPercent(positions, readingSinceMs) (where a timed session starts: the most recent position, not the higher one; a reread without a position of its own starts at 0), currentPage(total, percent, override, lastSessionEndMs) (a hand-set page wins until a later session), dayNumber(readStatus.startedAt, today), readingNumber(attempts, reading) (completed readings + the open one), pace(stats, total) (percent and pages per hour), timeLeftSeconds, pagesOf(progressDelta, total), trackingFile(files) (primary, else first: the file a timed session is recorded against).

The timer (TimerEngine, container.timer).

The book page’s tracking (feature.book). Under the Read/status/download buttons, a DashCard like Bookmory’s current-book card: “Day N” (reading, rereading or on hold), “First reading” / “Reread #N” (PageMath.readingNumber), “p. 130 / 464” (tap the total: the user’s edition, pageTotals; the pencil: a pageOverrides page, never file progress, which would wipe the ebook position), the bar, pages/h (only with a page total; never %/h) and time left, both hidden until trustedPace has 30 min of reading across 3 sessions, Start timer (Timer(bookId)), Log a session (manual session; a book not started yet is then set to reading, queued with the session when offline) and Mark as finished. Below the description and files: rating (only with canRate, disabled with a note when the book’s rating field is metadata-locked; tapping the current star clears it) and the private review, edited in place; then the reading log (timelineOf: attempts’ start/end and sessions by day, newest first, 25 sessions a page; readings older than the oldest loaded session wait for it; a page that repeats a session reloads the top pages), where tapping or long-pressing a session moves it (same duration) or deletes it (confirmed), and “Add a past read” creates an attempt. Day N, the log’s days and times and the dialogs’ dates and times are in the account’s timezone (BookTrackingUiState.zone), like the Calendar and Day screens. The dialogs scroll and are saved across recreation and process death (TrackingDialogSaver: sessions by id). Mark as finished sends status read with finishedAt = today in the account’s timezone (the server rejects “future” dates by its own clock), then a full-screen Dialog (FinishCelebrationContent): confetti, cover, message; then rating + review; then each claimCelebration() achievement, acknowledged when dismissed. Every write bumps bookVersion, which reloads the tracker and the book (status, rating, note). Beyond that the page asks again (the book, its position, sessions and readings: four requests) only when something this phone sent moves ReadingChanges (a second later while it shows, so the reader’s last push, which lands after the user is back on the page, is caught; else when it shows again), when the app was stopped while it showed, or once it is five minutes old. Back from the author’s books, the series, highlights or edit with nothing sent, nothing is asked. A move that comes after a refresh started asks again (its answers may be from before it), except the one a tracking write brings with its own reload (PageFreshness, BookDetailRefreshTest; the screen’s part: FollowShowing, BookDetailShowingTest).

The timer screens (feature.timer).

Statistics (feature.stats). Tracking > Statistics, two tabs over one filter row (library, when the user has more than one; the period 30 / 90 / 365 days / this year, which scopes the windowed charts: daily time, peak hours, weekdays, genres, archetypes, funnel; the others keep the web’s windows). Reading: the activity-overview (today, last 7 days against the 7 before with a 7-day strip that opens Day(date), streaks, books this year, the goal with ahead/on pace/behind and its monthly trajectory, the weekly rhythm), then 11 chart cards after the web’s statistics/components/user (reading time only, no listening). Library: the statistics/* summary and ranked lists (formats, storage, added over time, authors, series, genres, languages, decades, largest books). Each card has loading, “not enough reading yet” (the web’s thresholds) and tap-to-retry states; a reload keeps the old chart (and the overview and library figures) faded. Days are in the user’s time zone (users.settings.timezone): the heatmap and weekdays come from daily-reading’s local days (reading-heatmap stops at today in UTC, favorite-days takes UTC weekdays), archetypes are moved by the zone’s offset, the goal trajectory asks for 12 whole UTC months, and a new day since the last load (the screen kept, or the app left overnight) reloads every dated chart, on any reload or on showing again. The weekly chart leaves out a partial oldest week (its days still count in the total). Charts follow the dataviz skill: accent = the one series, grey = the comparison (a fainter grey beside the Grey accent), accent steps for magnitude and the funnel, a validated second colour for weekends; the selection is read only while drawing, so a tap doesn’t rebuild a chart.

Achievements and the year in review (feature.achievements).

The calendar and goals (feature.calendar).

Reading dates (feature.calendar: ReadingDates*). Start and end dates for a book, from the Calendar’s toolbar + (for today), a Day’s “Add a book for this day”, and History’s row actions.

The reading history (feature.history).

Readers

Every format the web reads, except audiobooks, opens in one of three readers. They share one look (the EPUB reader’s chrome: Compose top and bottom bars over the content, immersive, bottom sheets for contents and settings) and one data path (the same progress queue, sessions and offline copies), so tracking, the Dashboard and statistics see every format alike. All three keep the screen on only for 10 minutes after the last sign of reading (a page turn or move, the bars shown or hidden: KeepScreenOnWhileReading, READING_SCREEN_ON_MS); after that the phone’s own timeout applies, so a book left open doesn’t keep the screen lit.

Reader Formats Route Package
foliate (WebView) EPUB, KEPUB, MOBI, AZW3, AZW, FB2 Reader(bookId, fileId, title, cfi?, format?) feature.reader
Comics CBZ, CBR, CB7 Comics(bookId, fileId, title) feature.comics
PDF PDF Pdf(bookId, fileId, title, page?) feature.pdf

Which file and which reader (core.format.BookFormats, ui.nav.ReaderRouter).

Every format at a glance (checked end to end in the code):

Format Opens from (all through ReaderRouter) Offline copy Read from Position and sessions
EPUB Book page, Dashboard, Downloaded, notes book.epub + info.json (openEpub; an older download without format is this) epub/<id>/... via ReaderRequests ReaderViewModel → ProgressStore (cfi)
KEPUB, MOBI, AZW3, AZW, FB2 Book page, Dashboard, Downloaded, notes book.<ext> (openOffline) books/files/<id>/serve, whole, via ReaderRequests ReaderViewModel → ProgressStore (cfi)
PDF Book page, Dashboard, Downloaded, notes (a pageno opens that page) book.pdf (openOffline) PdfFiles: the copy, else serve into cacheDir/pdf PageReaderProgress → ProgressStore (pageNumber)
CBZ Book page, Dashboard, Downloaded book.cbz (openOffline) ComicSource: zip entries, else cbz/files/<id>/pages/<n> PageReaderProgress → ProgressStore (pageNumber)
CBR, CB7 Book page, Dashboard none (no RAR/7z reader on the phone; a CBZ beside it is kept instead) cbz/files/<id>/pages/<n> (the server extracts) PageReaderProgress → ProgressStore (pageNumber)

A visit that never moved records nothing (core.sync.ReadingVisit): until the user turns a page, scrolls, zooms or jumps since opening, no position is saved and no session sent (the server takes a file-route session as reading activity and sets the book to Reading by itself). A session that ends before the first move (the phone locked on the first page) is held and sent with that move; closing drops it. Every reader’s pushes run ProgressStore.onSent → ReadingChanges.changed(), so the Dashboard, the book page and the achievements toast refresh as they do for EPUB. Sessions (the same ReadingSession body for every format, with source: android like the timer’s, so the server doesn’t record them as web) go through enqueueSession and a forced sync to the same file routes, so the book page’s reading log, the calendar and statistics count them. SyncCoordinator takes server baselines for every downloaded file, whatever its format.

The foliate reader’s other formats (feature.reader, assets/reader/reader.js). KEPUB, MOBI, KF8/AZW3, AZW and FB2 open in the EPUB reader, as on the web (useFoliate.ts): the server’s epub/ routes serve EPUB files only, so these are read as one whole file with foliate’s makeBook.

The foliate reader’s layout (feature.reader: ReaderStyles, ReaderSettings.kt, ReadingTime, assets/reader/reader.js).

Offline copies (core.download). Any openable format can be downloaded except CBR and CB7 (BookFormats.canKeepOffline): the server extracts those archives and serves their pages as images (cbz/files/:id/pages/:n), and the phone has no RAR or 7z reader, so an offline comic is always a CBZ. The book page downloads BookFormats.pickOfflineFile(book) (the same pick among keepable files: a CBR comic with a CBZ beside it keeps the CBZ; one without offers no download). While that CBZ is kept (current, and still on the server), Read opens it online too, on the book page (BookDetailUiState.readTarget) and the Dashboard’s play buttons (HomeViewModel.fileToRead), so the user’s place stays in one file (BookFormats.readsKeptCopyInstead; positions are per file). The book’s progress on the server’s cards still follows its primary CBR.

Page-based progress (core.sync.PageProgress, PageReaderProgress).

Reader settings sync (core.readerprefs). The web’s per-format reader settings (reader/defaults, reader/preferences/:bookFileId; useReaderSettings.ts):

The comics reader (feature.comics). The web’s CbzReaderView in the EPUB reader’s chrome.

The PDF reader (feature.pdf).

Sorting and filtering the book grids (feature.library)

Every book grid (All books, each library, smart scope and collection, an author’s or a series’ books) has a sort action in its toolbar (SortButton, a dot while the list isn’t in its default order). It opens SortSheet: Sort by, the order in words for the field (A to Z, Newest first, Reading first…), and Show: Reading only (reading, re-reading), Hide read (status read), Hide unread (status unread or no status: the server reads a missing status as unread, and its filter does too). Changes apply at once. While the list isn’t at its default, SortChips shows under the toolbar (the sort, each filter, and Clear, which restores the default in one tap). A search inside a list keeps its sort and filters. Nothing matching with filters on shows why (both hide toggles on get their own explanation) and Clear filters.

Grid, list and the quick view (feature.library)

Every book grid (All books, each library, scope and collection, an author’s or a series’ books, and Downloaded) shows its books as covers in a grid or as rows in a list, at the cover size the user picked.

Highlights and notes (feature.notes)

BookOrbit’s annotations (web, KOReader and Kobo highlights; annotation.controller.ts, annotation-hub.controller.ts), read and edited on the phone. Highlights are made in the reader (see “Reader annotations”); these screens read and edit them.

The Dashboard (feature.home)

The web dashboard (client/src/features/dashboard, views/DashboardView.vue) in the Nexus layout.

Reader annotations (feature.reader.annotations)

The web reader’s highlights, notes, bookmarks and search (client/src/features/reader/epub: SelectionPopup, useAnnotations, useFoliateAnnotations, useFoliateSelection, useBookmarks, useSearch, NoteDialog) in the Nexus reader.

Footnotes (feature.reader)

foliate follows every link in a book, so a footnote reference jumped to the notes (often at the back) and the reading position, saved and synced, went there too. reader.js now cancels foliate’s link event for a footnote reference and reads the note instead (openFootnote): view.resolveNavigation(href), the section’s own copy (createDocument, so the page doesn’t move), the note element (one marked footnote/endnote/rearnote/note or doc-footnote/doc-endnote, else the aside/li/dd/p/blockquote holding the target), and its text with the links back left out (marked back-links, “↩”/”Back”, one pointing at the tapped reference’s id, a short marker opening the note). native.onFootnote({text, href}) puts it in ReaderUiState.footnote, and FootnoteSheet (ReaderFootnote.kt) shows it in the reader’s font with Close and Open in book (ReaderViewModel.openFootnoteInBook: goTo(href), a jump the user picked). A reference is one the book marks (epub:type="noteref", role="doc-noteref") or, for books that don’t, a link of up to six characters in or around a superscript, or one like “”, “†”, “12”, “[3]” (noteRefKind), outside nav. A guess whose target is a heading or over 1,500 characters (8,000 for a marked one), or that can’t be read, is followed as before, and so is every other link. FootnoteDeviceTest: an unmarked “” to a notes file, an EPUB 3 aside in the chapter, and a cross-reference still followed.

Looking words up (feature.reader.lookup)

The selection popup’s Look up (every foliate format: EPUB, KEPUB, MOBI, AZW3, AZW, FB2) opens a bottom sheet for the selected word or phrase, like the web reader’s DictionaryPopover but on Wiktionary and Wikipedia and with the phone’s own text apps. LookupViewModel (its own ViewModel on the reader’s navigation entry, made in ReaderScreen) holds the open sheet (LookupUiState, null while closed) and a LookupRepository, whose answers are kept in memory for the reading session. Strings: strings_reader_lookup.xml (reader_lookup_*).

Quotes (feature.quotes)

A quote typed in, or read off a photographed page, saved as a BookOrbit annotation (so it shows in Notes, on the book page, on the web and in exports). Entry points: the book page’s “Add quote” / “Scan a page” buttons under the Highlights card (QuoteButtons, the book fixed), and the Notes toolbar’s quote action (AddQuoteAction: the book the feed is filtered to, else the user picks one: Currently Reading first, or the library searched with books/query).

Next in series (feature.seriesnext)

When the user finishes a series book, the next one the user hasn’t read is offered: on the finish celebration, on the book page, on the timer’s result and on the foliate reader’s last page. It only reads: opening the next book is the user’s choice, and the reader’s own rule applies (opening without moving records nothing). The comics reader keeps its own next issue (see “The comics reader”).

Editing a book’s details (feature.bookedit)

A light editor for the four things the user fixes most, not the web’s full EditMetadataTab: the title, the authors, the series and its number, and the cover. Strings: strings_bookedit.xml.

Scanning ISBNs (feature.scan)

Paper books are tracked against the library’s copy, so the scanner’s job is to find that copy from the barcode on the back.

Dependency injection

AppContainer (in App.kt) creates each app-wide object lazily, once. Features never construct Api, Session, ProgressStore or Downloads themselves.

Screens: ViewModel + UiState + StateFlow

Every screen has three parts:

// feature/book/BookDetailViewModel.kt
data class BookDetailUiState(val loading: Boolean = true, val book: BookDetail? = null, ...)

class BookDetailViewModel(private val container: AppContainer, private val bookId: Long) : ViewModel() {
    private val _state = MutableStateFlow(BookDetailUiState())
    val state: StateFlow<BookDetailUiState> = _state.asStateFlow()
    fun download() { ... }                      // events are plain functions
}

// feature/book/BookDetailScreen.kt
@Composable
fun BookDetailScreen(route: Route.BookDetail, navigator: AppNavigator) {       // stateful, signature fixed by NavGraph
    val viewModel = appViewModel { BookDetailViewModel(it, route.bookId) }
    val state by viewModel.state.collectAsStateWithLifecycle()
    BookDetailContent(state, onBack = { navigator.back() }, onDownload = viewModel::download)
}

@Composable
fun BookDetailContent(state: BookDetailUiState, onBack: () -> Unit = {}, onDownload: () -> Unit = {}) { ... }  // stateless, screenshot-tested

Theme, icons and shared components

The look is the web client’s token system, computed on the device for the user’s settings, with the Nexus app’s card styles on top. Screens follow their Nexus layout and draw with these pieces.

Theme (ui.theme)

val c = OttershelfTheme.colors            // every web token: background, card, muted, mutedForeground (dim
                                         // text), surface1..4, border, primary/onPrimary, success/warning/
                                         // info/destructive, formatPill("epub"), readStatus(status),
                                         // dashCard/dashCardBorder, cardRow, coverSurface, shellSurface...
val r = OttershelfTheme.radii             // sm/md/lg/xl/xl2/xl3/shell from the radius option (+ ...Shape)
MaterialTheme.colorScheme.primary        // Material roles are mapped from the same tokens
MaterialTheme.typography.titleMedium     // 15sp semibold card titles; the table is in Typography.kt

Icons (ui.icons)

Shared components (ui.components)

Component What it is (Nexus / web source)
BookCover(model, title, modifier, authors, seed, shape, requestWidth, overlay = { }) Coil cover in a 2:3 box, radius sm, cover surface while loading; drawn by FittedCoverImage (below), so a cover of another shape is never cropped; the web’s generated placeholder when there is no cover or it fails. model = api.coverModel(book) (versioned thumbnail URL, or null) or a downloaded book’s File. requestWidth (optional): asked for at that size at once, any bigger cached copy serving and shown while a bigger one loads (sizedCoverRequest).
BookCoverPlaceholder(title, authors, seed) The web’s gradient cover (book-cover.ts palette, lattice, frame, title, author); initials below 64dp wide. The title shrinks so its widest word fits (widestWord: all the words in one unwrapped layout, a word a line; PlaceholderMeasureTest).
BookGridItem(book, cover, onClick, status, showFormat, onLongClick, coverWidth) A grid cell (item_book.xml): 6dp padding, series top right, status disc bottom right (30% of the width), progress on the bottom edge, title 13sp medium, authors 12sp dim. BOOK_GRID_MIN_CELL is 120dp (the book grids’ default size: three columns on a phone; see “Grid, list and the quick view”), BOOK_GRID_SPACING 6dp. Pass the ReadingChanges override as status; onLongClick (optional) for the quick view; coverWidth (optional) is passed on as BookCover(requestWidth).
ShelfCover(book, cover, onClick, width = 120.dp, onLongClick) A shelf cover (item_shelf_book.xml): status top left, series top right, format bottom right, progress; onLongClick (optional) for the quick view.
StatusBadge(status, size), StatusIcon(status, size) The status icon in its colour on the dark disc (nothing for unread); the plain coloured icon for menus.
FormatBadge(format), FormatChip(format), primaryOpenableFormat(files) White caps on the format colour at 90% (covers); a text chip in the per-theme pill colour; which format the web badges.
SeriesBadge(index, small), CountPill(count) “#4” on a dark pill; a count beside a title.
PillProgressBar(progress), CoverProgressBar(progress, read) Rounded track and fill, 6dp (progress is 0..1); the 3dp bottom-edge bar on covers (primary 70%, green once read).
DashCard(onClick, containerColor, borderColor, shape, contentPadding) { } Dashboard card: card at 30% over the page, 1dp primary/40 edge, 2xl radius. A plain card: containerColor = colors.card, borderColor = colors.border, shape = radii.lgShape.
SectionHeader(title, icon, count, trailing), CardTitle(title, icon), CardRow(onClick) { } Shelf header (28dp framed icon, 15sp bold, count pill); widget title (16dp accent icon); a row inside a card (muted 20%, 40% pressed).
EmptyState(message, icon, compact, action), ErrorState(onRetry, message, detail, compact), LoadingState(), FullScreenLoading(), SkeletonBox() Muted disc and dim text; dim text and an accent Retry, the whole area retries when tapped; accent spinner; pulsing placeholder block. Static in previews and screenshot tests.
AccentButton(text, onClick, icon), SecondaryButton(text, onClick, icon) Accent fill, or card fill with a border; md radius; optional Lucide icon.
DetailTopBar(title, onBack, subtitle, actions) A pushed screen’s Nexus toolbar: Back, 19sp title with an optional dim subtitle, 56dp on the shell surface, a 1dp divider; takes the status bar inset (put it in a Scaffold’s topBar).
ImmersiveSystemBars(barsVisible) A reader’s immersive mode: the system bars hide while barsVisible is false (swipe shows them for a moment) and come back when the last immersive screen leaves; the newest on the window decides, so a reader replaced by another (Read next) doesn’t show them over the new one as it fades out (ImmersiveOwners, ImmersiveSystemBarsTest).
KeepScreenOn(on), KeepScreenOnWhileReading(vararg activity) The screen stays on while on (the timer while it runs, if the user wants it). The readers’: for 10 minutes (READING_SCREEN_ON_MS) after the last change in activity (the page or position, the bars shown or hidden) or the reader’s last return on screen (its ON_START event, counted as an event rather than read from the lifecycle’s state: back from the lock screen or another app, before any page is turned); then the phone’s own timeout applies. Each screen holds the window view’s keepScreenOn for itself (ScreenOnHolders), so the one leaving during the crossfade (Read next, the timer opened over a reader from its notification, Back from it) never turns it off for the one arriving (ReadingScreenOnTest).
FittedCoverImage(model, contentDescription, modifier, onSuccess, onError) A cover image filling its box as the web shows covers by default (“blurred fit”, BookCoverArtwork.vue): the whole cover, fitted, over the same image enlarged 10%, blurred 12dp and dimmed to 90% when its shape differs from the box’s by more than 3% (a tall paperback, a square audiobook cover); within 3% it fills the box as a crop did. The backdrop reuses the decoded image (nothing more is fetched). Used by BookCover, the book page’s cover, the cover search and the collection fans; author portraits still crop to their circle. CoverFitDeviceTest, FittedCoverTest.
Modifier.belowStatusBar() Every ModalBottomSheet’s modifier (pass it to new sheets too): keeps the sheet below the status bar. Without it a sheet tall enough to reach the bar pads its content by however much of the bar it covers, so its height follows its own offset, and a hard fling to the end of a long list (the cover search) left the sheet shaking up and down until the next touch.

Components read OttershelfTheme only, never the container, so screenshot tests render them directly. Their strings are in strings_components.xml (components_*).

The layout is the Nexus app’s (MainActivity, activity_main.xml): a navigation drawer holding every list, a toolbar over the list it shows, and full screens pushed on top. There is no bottom bar.

Routes. Every screen is a @Serializable Route : NavKey in Routes.kt: Login, Home (the Dashboard), BookList(sourceKey, title, query?), Authors(query?), Series(query?), Downloads(query?), BookDetail(bookId), BookEdit(bookId) (feature.bookedit), Reader(bookId, fileId, title, cfi?, format?), Comics(bookId, fileId, title), Pdf(bookId, fileId, title, page?) (always built by ReaderRouter, see “Readers”), Requests, Settings and Appearance; for the tracker, Calendar (root, drawer key calendar), History (root, drawer key history), Day(date), ReadingGoals, Timer(bookId) and TimerResult(bookId, sessionId); Statistics and Achievements (roots, drawer keys statistics, achievements) and Rewind(year?); Notes(query?) (root, drawer key notes, searched with the shell’s search), BookHighlights(bookId, title) and Memorize(bookId?, liked); AddQuote(bookId?, title?, bookFixed, scan) (feature.quotes); Scan(forTimer, pick) and RequestBook(isbn, title?, author?) (feature.scan, see “Scanning ISBNs”). Reader takes an optional cfi: it opens at that highlight and, as after a fresh start, saves nothing until a page is turned (the user’s own move: reader.js marks a relocate turned for a turn, swipe or scroll, and the TOC, slider and sheet jumps count; foliate’s settling relocates don’t).

Chrome. Each route’s chrome tells the shell how to frame it:

The back stack (AppNavigatorState) is one Navigation 3 stack: the bottom entry is the root list (root), the entries above it are pushed screens (current is the top).

The drawer (AppDrawer.kt, a Material 3 ModalNavigationDrawer, 300dp wide like the Nexus drawer, at most screen width - 56dp; swipe or the toggle opens it, the scrim (DrawerScrimColor, the Nexus DrawerLayout’s 60% black) or Back closes it, with predictive back). It only opens over a root list.

Search (the toolbar’s Search action expands into a field; the Nexus beforeSearch):

AppNavigator is what screens get: navigate(route), replace(route), back() and dashboardLoaded(hasContent).

Snackbars. The shell shows download results (in the background they are notifications), ThemeSyncEvent.SaveFailed and TrackingEvent.SessionRejected (a queued reading session the server refused). Earned achievements show as a toast at the top (AchievementCelebrationHost).

Opened from a notification (the timer’s): PendingRoute holds the route MainActivity read from the intent; the signed-in shell navigates there (a TimerResult over its own Timer replaces it) and consumes it. While signed out it waits for the next sign-in. MainActivity is exported (the launcher), so any app can send those extras: a session id is taken only as TimerEngine makes it (a UUID), else the book’s Timer opens, so a huge one can’t overflow the saved back stack (PendingRouteTest).

Sign-in and sign-out. AppRoot shows the Login route while AuthState.status is SignedOut. Otherwise it shows AppScaffold, keyed by account. So:

Adding a route is a shared change: the Route in Routes.kt and one line in NavGraph.kt. The when there is exhaustive, so a route without a screen doesn’t compile. A new root list also needs a sourceKey/query case, a title in rootTitle and a drawer row.

Strings and resources

Screenshot tests (Roborazzi + Robolectric)

There is no emulator. Check every screen as PNGs, in light and dark, at phone size.

// app/src/test/java/io/github/ottershelf/feature/book/BookDetailScreenshotTest.kt
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "xxhdpi")
class BookDetailScreenshotTest {
    @Test
    fun loaded() = captureLightAndDark("book/detail_loaded") {
        BookDetailContent(BookDetailUiState(loading = false, book = sampleBook))
    }
}

Device tests (app/src/androidTest, on a phone)

What Robolectric can’t run is checked on a real phone (io.github.ottershelf.devicetest): the foliate reader in the phone’s WebView, the native barcode and page readers (zxing-cpp, Tesseract), Look up’s client on the phone’s network, and the library grid’s pinch and flings under real touches.

Release build (R8)

Build types

Two build types minify with R8 (proguard-android-optimize.txt + app/proguard-rules.pro) and shrink resources; the third is for tests:

Build type APK Package, name Signed with Used for
dev apk/dev/app-dev.apk devApplicationId (default io.github.ottershelf.dev), “Ottershelf (dev)” the debug key, always day-to-day testing on a phone, and test builds (pre-releases)
release apk/release/app-release.apk (app-release-unsigned.apk without the key) io.github.ottershelf the release key, once it exists releases
debug apk/debug/app-debug.apk as dev the debug key unit, Roborazzi and device tests (testBuildType), devtools

The app’s own rules keep what is reached by reflection:

Names that are stored

Some names outlive a build: they are stored on the device, on the server, or by Android itself, so renaming them in the code would lose data. Keep them as they are:

Ownership: features don’t edit each other’s files

Features are built in parallel branches. To merge cleanly:

  1. A feature changes only its own package and its own resources:
    • feature/<name>/**
    • res/values/strings_<name>.xml and <name>_-prefixed resources
    • src/test/.../feature/<name>/**
  2. Keep screen signatures stable (the ones NavGraph.kt calls), such as HomeScreen(navigator, contentPadding) and BookDetailScreen(route, navigator). Replace the placeholder bodies, not the signatures.
  3. Shared files belong to someone else. Change them only when your change needs it, keep the change small and additive, and name each one in the pull request:
Shared file Who changes it, and how
gradle/libs.versions.toml, app/build.gradle.kts, settings.gradle.kts Add a library (version in the catalog). Never bump existing versions casually. Libraries come from Google’s Maven and Maven Central; the one exception is JitPack, allowed for Tesseract4Android’s group only (see “Security and privacy”)
app/src/main/AndroidManifest.xml, app/src/debug/AndroidManifest.xml Permissions or components a feature really needs (e.g. configChanges for the reader); the removals there (the startup initializers, the debug tooling activities) are deliberate: see “Security and privacy”
App.kt (AppContainer), core/** The data-layer owner. Features call these APIs; small additive methods are OK when needed.
ui/nav/Routes.kt, ui/nav/NavGraph.kt Only to add a route or change a route’s parameters
ui/nav/* (other files), MainActivity.kt Shell owner
ui/theme/**, core/theme/**, res/values/colors.xml, themes.xml Theme work
ui/theme/patterns/AppearancePatterns.kt The appearance work: add the remaining background patterns here
ui/icons/**, tools/gen-lucide-builtin.sh Icon work (others may add a built-in icon: a name in the script, then run it; or a vector in AppIcons)
ui/components/**, strings_components.xml Add a new file for a truly shared component; don’t change existing ones’ behaviour
res/values/strings.xml, strings_nav.xml, strings_core.xml Shell and data layer only. Features use their own strings file.
assets/** Reader work (foliate, reader); icon work (lucide.txt)
app/proguard-rules.pro Add keep rules for your own code if R8 needs them
ARCHITECTURE.md Update the part your work changes

Security and privacy