Skip to content

Fix: solidify the download/caching/storage pipeline#70

Merged
Aatricks merged 20 commits into
mainfrom
fix/download-pipeline-solidify
Jul 2, 2026
Merged

Fix: solidify the download/caching/storage pipeline#70
Aatricks merged 20 commits into
mainfrom
fix/download-pipeline-solidify

Conversation

@Aatricks

@Aatricks Aatricks commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Fixes downloads lying about downloaded chapters and general unreliability. Stacked on #69 (architecture cleanup) — merge that first; this PR's own commits start at the offline-store fix.

Root causes fixed

  1. Text-only (novel) chapters could never finish downloading — the offline store refused zero-image chapters, so the worker retried forever and the reconciler stripped isDownloaded from already-downloaded chapters.
  2. Dead-image chapters retried forever — permanent 404s were recorded only in a branch that became unreachable when the offline store took over user downloads; auto-resume re-enqueued the same chapters on every library visit.
  3. The badge trusted stale WorkManager records — days-old SUCCEEDED records could resurrect "Downloaded" after deletion; data-less CANCELLED records could wipe a valid downloaded state.

Changes

  • WebOfflineChapterStore: zero-image text chapters persist a complete manifest; permanent image failures are recorded (24h TTL) and counted as terminal — badge shows "Download incomplete: n/m" with retry instead of an infinite spinner; both inspect paths now agree
  • ChapterDownloadWorker: retry budget on the retryable path; final DB-flag reconcile reads disk, so a remove-download racing a finishing worker can't re-promote
  • WebContentLoader: dead USER_REQUESTED prefetch branch deleted (~300 lines); the manifest store is the sole persistent artifact — downloads-tier HTML no longer written
  • LibraryDownloadStates: WorkManager emissions supply in-progress states only, disk inspects own terminal states; optimistic enqueues roll back on failure; auto-resume capped at 2/session; finished WorkInfos pruned at startup
  • Deletion wiring: deleting chapters cancels queued work and drops badge-map entries; removeDownload refreshes state immediately
  • Legacy storage sweep (pipeline v3): orphaned downloads/html + downloads/media deleted on next start; manifests and flags untouched
  • Reader: tiled webtoon strips resolve file:// URIs from the manifest store (fixes black strips reading downloaded chapters offline); ImageBoundsParser handles lossy VP8 WebP so tall strips keep tiling
  • AGENTS.md added (build gate, detekt discipline, conventions)

Verification

384 tests / 0 failures (35 new), detekt clean with baseline entries only removed, assembleStandardDebug + assembleAiDebug green. Manually confirmed offline manhwa reading renders all strips.

Aatricks added 20 commits July 1, 2026 10:38
Behavior-preserving cleanup of the reader restore logic.

- Extract the unified scroll-restore logic out of the ContentArea composable into
  two named functions: runScrollRestore (orchestrator) and awaitStableRestore (the
  watch-until-stable loop). The restore effect is now a one-line call.
- Restructure the orchestrator's early returns into a single when/handled flow so
  each function stays within detekt's complexity limits; the loop's essential
  interrelated conditions carry a documented @Suppress("CyclomaticComplexMethod").
- Document every restore magic constant (poll cadence, stability window, percent
  tolerance, stability threshold, ghost-row percent, sentinel item size).
- Add a doc block above ReaderProgressController's gating flags describing the
  transitions and the semi-independent axes.

No runtime logic, thresholds, control flow, or ordering changed. Verified:
./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures, detekt clean).
ReaderViewModel aliased four ReaderProgressController fields as its own
pass-through properties. Three (suppressAutoNavUntilUserInteraction,
restoredScrollPercent, restoredProgressSnapshot) were never referenced;
hasUserInteractedSinceLoad was read once internally and not by any UI code.
Remove all four and route the single remaining read directly through the
controller. The controller's own fields are unchanged (its tests use them),
and the members still used by the reader UI (userHasDragged, restoreInProgress,
currentLibraryItemId) stay.

Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures,
detekt clean).
…nManager

Move the resolved-image-dimension machinery out of ReaderViewModel into a new
ImageDimensionManager: the fine-grained Compose state map, the prompt off-main
cache flush, and the trailing-debounced content rebuild (with its jobs and
pending/queued maps). ReaderViewModel keeps the ui-state-coupled content rewrite
(updateCurrentContentImageDimensions / withResolvedImageDimensions) and passes it
to the manager as a callback; it exposes the same public surface
(resolvedImageDimensions, persistImageDimensions) by delegation, so callers and
the reader UI are unchanged. resetState now calls manager.reset(), preserving the
original behavior of leaving the db-flush job and pending map running.

Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures,
detekt clean).
…entShaper

WebContentLoader mixed ~130 lines of pure, stateless element shaping (long-strip
detection, adjacent-image grouping, wide/double-page splitting) into its I/O and
caching orchestration. Move those six functions and their two constants verbatim
into a new stateless WebChapterElementShaper object; WebContentLoader now calls
through it at the three existing sites. No behavior change.

The moved code's pre-existing, already-baselined detekt findings (magic numbers,
long lines, nesting/return-count on the grouping functions) are relocated in
detekt-baseline.xml from WebContentLoader to WebChapterElementShaper — same
findings, new home.

Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures,
detekt clean).
…nager

Move LibraryViewModel's selection state (_selectedItems, _selectionModeEnabled)
and the toggle/select/deselect/group/all/enter/clear operations into a new
LibrarySelectionManager. The ViewModel delegates and reads the manager's flows in
its state combine; group- and select-all operations pass their ids in, keeping the
manager decoupled from the library list and ui state. No behavior change.

Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures,
detekt clean).
Move LibraryViewModel's pending-deletion state and the 5s undo-window logic
(_pendingDeletion, the delete job, schedule/undo/commit/flush/removeImmediate) into
a new LibraryDeletionCoordinator. The ViewModel delegates and reads the
coordinator's pendingDeletion flow in its state combine; deletion errors surface
back through a callback. No behavior change.

Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures,
detekt clean).
Move LibraryViewModel's search/content-type/sort/status filter flows, their setters,
and the pure filterAndSortItems function into a new LibraryFilters holder. The
ViewModel exposes the flows by delegation and applies them via filters.apply() in its
state combine. No behavior change.

Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures, detekt
clean).
…tates

Move LibraryViewModel's per-chapter download/cache badge state and its
reconciliation — the queue observer, on-demand reconcile + auto-resume of
incomplete downloads, and the semaphore-limited cache-state refresh — into a new
LibraryDownloadStates. The ViewModel delegates the public reconcile/refresh methods,
reads the manager's chapterCacheStates in its combine, and routes its three
remaining setCacheState writes through it. No behavior change.

Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures, detekt
clean).
With selection, deletion, filters, and download/cache-state extracted into their own
collaborators, LibraryViewModel's class body is now under detekt's LargeClass
threshold, so the suppression it carried is no longer needed.

Verified: ./gradlew detekt (clean, no LargeClass).
Drop pre-Room content/session persistence that nothing reads anymore:
saveParagraphs/loadParagraphs, saveLibraryItems, the currentUrl / currentTitle /
scrollPosition properties, clearContent, and their now-orphaned keys.
loadLibraryItems stays (LibraryRepository still uses it for one-time migration to
Room). With the dead functions gone, PreferencesManager is under detekt's
TooManyFunctions threshold, so its @Suppress is removed too.

Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures, detekt
clean).
Delete confirmed-unused private members that were baselined rather than removed:
nine dead regexes in TextUtils (one a duplicate of MULTIPLE_SPACES_REGEX),
HtmlParser's unused WHITESPACE_REGEX, ReaderViewModel's unused
MIN_SCROLL_OFFSET_DELTA_PX constant and isPlaceholderAtCurrentPosition (a duplicate
of the live one in ReaderProgressController), and MangaBatSource's unused
fetchChaptersFromApi fallback. Regenerated detekt-baseline.xml to drop the 13
now-stale entries.

Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures, detekt
clean).
…ailures

WebOfflineChapterStore refused zero-image chapters (clear + retryable), so
text-only novel chapters could never finish downloading and WorkManager
retried them forever; permanent 4xx image failures were never recorded on
the download path, so dead-image chapters also retried forever.

- Classify zero-image documents via a new ChapterDocumentClassifier
  (extracted detectMangaReaderHints + isRenderableTextChapter): renderable
  text chapters persist a complete empty-images manifest; JS shells return
  the existing inspect state instead of clearing a good download.
- Record permanent image failures into PermanentFailureStore from
  downloadMissingImages, classifying via ImageFetchResult.isRetryable().
- inspect() now accounts fresh permanent failures toward isComplete (with
  hasPermanentFailures=true, isRetryable=false) so such chapters go
  terminal instead of retrying; cachedImages stays the on-disk count.
- validateManifestFiles accepts empty image lists so loadContent serves
  text chapters offline. Manifest schema unchanged (v1).

New WebOfflineChapterStoreTest covers text chapters, shells, refetch
safety, permanent/retryable failures, inspect accounting, and legacy
manifest compat.
The retryable branch of ChapterDownloadWorker returned Result.retry()
unconditionally — MAX_RUN_ATTEMPTS only guarded the exception path — so a
chapter that could never complete retried forever. The final reconcile
also used the worker's in-memory result, so a remove-download that raced
a finishing worker could re-promote isDownloaded for files just deleted.

- Cap the retryable path at MAX_RUN_ATTEMPTS, then fail with terminal data.
- Reconcile from a fresh inspectDownload() so the DB flag tracks on-disk
  reality; the returned WorkManager Result still maps from the in-memory
  result so a post-removal empty inspect can't trigger a re-download.

Worker tests updated to consecutive inspectDownload stubbing; new tests
for attempt-budget exhaustion and cleared-disk non-promotion.
Since the offline store took over user downloads, executePrefetch's
USER_REQUESTED mode early-returns through offlineChapterStore; the 2-pass
cacheImages(DOWNLOADS) loop, permanent-failure recording, and the
persistentOnly inspect below it were unreachable, and the document fetch
still wrote raw HTML into the persistent downloads dir on every download.

- Fetch HTML with writeTier=CACHE for both prefetch modes; the manifest
  store is the sole persistent artifact for downloaded chapters.
- Delete the dead members: cacheImages, ImageBatchDownloadReport,
  ensureResultForTier, the DOWNLOADS-promotion branch and writeTier param
  of downloadAndCacheImageInternal, tierForMode, recordPermanentFailures,
  loadPermanentFailures, migrateLegacySidecarIfPresent, and the pass/
  concurrency constants.
- inspectCacheInternal loses persistentOnly and the cache-side permanent-
  failure accounting; cache inspects now always report a non-persistent,
  failure-free view (downloads-tier truth lives in the offline store).

detekt baseline regenerated: entries for deleted/shrunken members removed,
none added. New test asserts user downloads leave the html downloads dir
empty.
…kInfos

The badge map merged every observeAll() emission last-writer-wins.
WorkManager keeps finished records for days, so a stale SUCCEEDED record
could resurrect a deleted chapter's Downloaded state and a data-less
CANCELLED record (defaults isPersistentDownload=true) could wipe a valid
one; both raced the disk-inspect refresh. Optimistic isInProgress writes
had no rollback and auto-resume re-enqueued without limit from a stale
DB snapshot.

- Queue observer merges only in-progress states; an in-progress→terminal
  transition triggers a disk inspect for those urls — the inspect is the
  only writer of terminal states. Non-in-progress writes are dropped for
  urls with live queue work (both directions of the race).
- enqueue() now reports failure; new markPendingAndEnqueue() rolls the
  optimistic state back when enqueueing fails. New removeCacheStates()
  for deletion cleanup.
- Auto-resume: capped at 2 attempts per session, skips terminal
  permanent-failure results, and re-checks the item still exists with a
  fresh DB read before each enqueue.
- Prune finished WorkManager records once at app startup so the first
  observeAll emission isn't a graveyard of stale terminal states.

New LibraryDownloadStatesTest covers the arbitration, rollback, caps,
and cleanup.
…queues

Deleting chapters left their queued downloads running and their badge-map
entries in place (stale state that resurfaced if the chapter was
re-added), and removeDownload left the old downloaded badge until some
unrelated refresh. The three optimistic enqueue sites could strand a
permanent spinner when enqueueing failed.

- LibraryDeletionCoordinator reports successfully removed urls; the
  ViewModel cancels their queued work and drops their cache states.
- removeDownload refreshes the chapter's cache state after demoting the
  flag so the UI reflects not-downloaded immediately.
- addChapters, prefetchLibrary, and retryDownload route through
  markPendingAndEnqueue (rollback on failure); user-initiated paths
  surface a queueing error.
Nothing writes downloads/html or downloads/media anymore — downloaded web
chapters live entirely in the web_chapters_v2 manifest store. Bump the
web offline pipeline version to 3: installs at v2 get a light sweep that
deletes the orphaned legacy dirs (manifests, DB flags, and queue state
untouched); pre-v2 installs keep the existing full reset. clearDownload
drops the Jsoup re-parse used to delete legacy per-image files and keeps
only the manifest clear, cheap legacy html/.failed deletes, memo
eviction, and permanent-failure clearing.
Downloaded chapters serve images as file:// URIs rewritten by the offline
manifest store, but ReaderImageTileFetcher.resolveFile only knew the
URL-hash media-cache lookup and a network fallback — both dead ends for a
file URI. Every tall strip (anything past the 2048px tiling threshold)
rendered its slices black in airplane mode; only short untiled images,
which go through Coil's built-in file fetcher, survived.

Resolve file: URIs directly to the local file (when present and
non-empty) before the cache/network path. New ReaderImageTileFetcherTest
covers the file-URI fast path, the missing-file fallback, and the
unchanged http path.
ImageBoundsParser handled only the VP8X and VP8L WebP chunk variants, so
plain lossy WebP — the most common flavor on manga CDNs — stored 0x0
dimensions in offline manifest records. Zero dimensions make
readerImageSliceCount fall back to a single slice, so tall strips skip
the tiled renderer and decode as one giant bitmap, reintroducing the
software-draw scroll stutter tiling exists to avoid.

Add a 'VP8 ' branch that validates the keyframe start code and reads the
14-bit little-endian dimensions. New ImageBoundsParserTest covers lossy
VP8 (incl. start-code rejection and dimension masking) plus regression
cases for VP8X, VP8L, PNG, JPEG, and unknown payloads. Already-stored
0x0 records self-heal at the reader layer once an image decodes (the
dimension manager persists real sizes), so no manifest migration.
@Aatricks Aatricks merged commit ff4f54c into main Jul 2, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant