Skip to content

Download Resources from the Publish API - #69

Merged
283375 merged 14 commits into
masterfrom
feat/api-temp
Sep 9, 2026
Merged

283375 merged 14 commits into
masterfrom
feat/api-temp

Conversation

@283375

@283375 283375 commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the ability to download packlist, songlist and the chart info database from the publish API (Database Manage), and the image hashes database from the network (OCR Dependencies).

Changes

  • shared/core/api: Ktor-based ArcaeaResourcesApiClient (index.json fetch, HEAD probes, streamed downloads) and RemoteResourcesInfoStateHolder; per-platform engines (OkHttp / CIO) with shared timeout bounds sized for the largest published file (~1MB)
  • Database Manage: download section with a remote status panel (version, built_at, per-file error text) and manual refresh
  • OCR Dependencies: ih.db download validated the same way as manual import, then swapped into place via staging file + atomic move so an interrupted copy cannot destroy the current database
  • Settings: configurable resources API base URL with validation and reset
  • ImportTaskQueue extracted from DatabaseManageViewModel; download failures are logged under per-resource tags instead of the queue's raw fallback

Notes

  • Base URL changes trigger an automatic re-probe; download items are disabled while remote status is unknown
  • Manual ih.db import now also uses the staged atomic-move swap
  • Unit tests over MockEngine cover the client, failure isolation and the state holder

* Ktor client with OkHttp (android) / CIO (jvm) engines and shared
  HttpTimeout bounds sized for the largest published file (~1MB)
* Fetch index.json, HEAD-probe published files, stream downloads;
  per-file error text instead of thrown exceptions
* State holder exposing remote info with error text, keeping known
  metadata on transient refresh failures
* Unit tests over MockEngine for probing, failure isolation and the
  state holder
* Persisted in AppPreferences, defaults to the official publish URL
* Edit dialog with reset-to-default in general settings
* Extract the serial task queue from DatabaseManageViewModel into
  ImportTaskQueue; downloads log start and failure under per-resource
  tags instead of falling through to the queue's raw fallback
* Download section with remote status panel: per-file version and
  built_at display, manual refresh, items disabled while fetching
* Validate the downloaded database the same way as manual import,
  then swap into place via a staging file and atomic move so an
  interrupted copy cannot truncate the existing ih.db
* Manual import now uses the same staged swap
* Refresh remote info automatically when the base URL changes; a
  refresh in flight queues a rerun with the new URL instead of the
  change being dropped by the isFetching guard
* Disable download items while remote status is unknown; validate the
  base URL (http/https) in settings, default the UI state
* ImportTaskQueue: isWorking stays on between queued tasks, parent Job
  required explicitly instead of `!!`
* Download ih.db straight to the staging file, dropping the intermediate
  cache copy; note the application-context contract on the OCR VM
* Deterministic holder/client tests: MockEngine pinned to the test
  scheduler, plain scope instead of backgroundScope (advanceUntilIdle
  stops on background-only tasks), chunked download, HEAD/GET and index
  edge-case coverage
The four HEAD probes run concurrently, so their order is not part of the
contract; asserting the exact sequence failed on CI. Collect URLs from the
engine's thread-safe requestHistory instead of racing on a shared list.
@283375

283375 commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

/opencode review this pr

@opencode-agent

opencode-agent Bot commented Sep 8, 2026

Copy link
Copy Markdown

I reviewed the full diff plus supporting code, compiled :shared:jvmTest (passes) and :app:compileStableDebugKotlin (passes) on the head branch. Overall this is a well-structured, well-tested PR. Findings below.

Verdict

Solid work: clean layering (shared client / app consumers), per-resource failure isolation with error text, good MockEngine unit coverage, and the staged-swap / atomic-move idea is the right instinct. A few correctness/robustness concerns before merge.

Issues

1. atomicMove overwrite assumption is wrong on Android/JVM — the pre-delete creates a data-loss window

OcrDependenciesScreenViewModel.kt:137-141 and :215-219 do:

if (metadataOrNull(target) != null) SystemFileSystem.delete(target)   // gap here
SystemFileSystem.atomicMove(stagingPath, target)

with the comment "atomicMove does not overwrite". SystemFileSystem.atomicMove delegates to Files.move(src, dst, ATOMIC_MOVE) (verified in the kotlinx-io 0.9.0 bytecode), and on Linux/Android that is POSIX rename(2), which replaces an existing file. I confirmed with a direct Files.move(..., ATOMIC_MOVE) test on this machine: existing destination was overwritten, no exception.

So on the actual target runtimes the delete is unnecessary and only widens the window in which ih.db is absent (crash/kill between delete and move permanently loses the current DB; a concurrent OCR reader gets NoSuchFile). Since overwrite-vs-throw is implementation-dependent, prefer a no-gap swap that is safe under either behavior: atomicMove(target, backup)atomicMove(staging, target) → delete backup (optionally restore backup if the second move throws).

2. Two writers share one staging path and can run concurrently

Both manual SAF import and remote download use the same parentDir / "image-hashes.db.staging" (OcrDependenciesScreenViewModel.kt:201 and :123). The buttons are independent — a user can pick a file while a download is running (or vice-versa), and ImageHashesDatabaseBuilderJob (WorkManager, ImageHashesDatabaseBuilderJob.kt:56-65) can concurrently rewrite the final file. Two flows truncating the same staging file will corrupt each other's swap. Recommend a per-operation unique staging name, and/or a mutex so all three ih.db writers are serialized.

3. Stale remote info shown after a failed re-probe following a base-URL change

RemoteResourceDownloadItem.kt:28-33 prioritizes fileInfo over refreshErrorText, and :53 enables the download from fileInfo.isAvailable. After a base URL change the holder re-probes automatically (RemoteResourcesInfoStateHolder.kt:42-52); if that probe fails it deliberately keeps the old info and sets errorText. The items then keep showing the old URL's version/built_at and stay enabled, but every download will fail against the new URL — the refresh error is invisible. Consider recording which base URL produced info and treating info fetched under a different URL as unknown (disable + show the error).

4. Behaviour change: leaving the screen now cancels an in-flight import

ImportTaskQueue.kt:35-39 anchors running tasks to the parent Job, so navigating away from Database Manage clears the ViewModel and cancels a task mid-write. The old code ran the current task in a detached SupervisorJob() scope (DatabaseManageViewModel.kt, removed) so it completed. Each import does several sequential batch writes (songs, then difficulties, then localizations…); cancelling between them can leave a partially applied import. If tasks should survive navigation, anchor the executing scope elsewhere (e.g. an app-scoped scope) and only drop queued tasks on parent cancellation. Worth confirming intent.

5. Minor: no size bound on network downloads

Manual ih.db import enforces a 20 MB cap (isFileTooLarge), but requestImageHashesDatabaseDownload and the DB-manage downloads don't. HttpTimeout.requestTimeoutMillis bounds time, not size; a misconfigured/malicious publish URL can fill storage before the post-download SQLite validation rejects it. A Content-Length/streaming cap (or the same isFileTooLarge check post-download) would close the gap.

Nits

  • DatabaseManageScreen download failures surface only in the import-log bottom sheet (no inline per-item error), while the OCR flow shows errorText inline — slightly inconsistent UX.
  • Settings accepts http:// URLs, but Android blocks cleartext by default (API 28+), so an http base URL fails with a confusing error. Worth a hint or a networkSecurityConfig that only permits the user-configured host.
  • RemoteResourcesInfoStateHolder.refresh() check-then-act on isFetching isn't atomic across the UI/collector/rerun threads; a simultaneous double-refresh can fire two probes. Benign (idempotent), but a Mutex/update on the state would make it airtight.
  • Koin constructs the holder lazily only when those two screens open, so no startup network surprise — good.
  • fetchRemoteInfo/downloads re-fetch index.json per operation (downloads re-resolve versionedPath); minor extra round-trip, acceptable.

Tests

Good coverage: index parsing, HEAD-vs-GET probing, per-file failure isolation, chunk-boundary download verification, and holder behaviour (init refresh, dropped concurrent refresh, degraded index failure, rerun-on-base-url-change). One gap: no test pins down the atomicMove overwrite semantics that issue #1 depends on — worth adding a staging-swap test once the strategy is settled.

Minor: ArcaeaResourcesApiClientTest/holder tests reference java.io.IOException directly from commonTest; fine today (jvm/android targets only), but it would break if an iOS target is ever added.

New%20session%20-%202026-09-08T08%3A18%3A02.180Z
opencode session  |  github run

* atomicMove replaces an existing destination (kotlinx-io passes
  REPLACE_EXISTING), so drop the pre-delete that could leave ih.db
  missing between the delete and the move
* Serialize the two ih.db writers through the UI: manual import is
  disabled while a download runs and vice versa
* Clear probed info when the base URL changes and discard results from a
  probe that started against the previous URL
* Cap every resource at 20MB: fail fast on a declared oversized body and
  bound the streamed byte count for chunked responses
java.nio.file needs API 26+ and cannot be desugared, so kotlinx-io's
atomicMove always throws UnsupportedOperationException on API 24/25 and
the whole ih.db swap (manual import and network download) fails there.
Catch it and retry via File.renameTo, which maps to rename(2) on every
Android level: atomic and replacing an existing target, given both paths
sit on the same filesystem.
Download errors were only recorded in the import-log sheet, which users
have no reason to open after a failed download; the item silently
returned to idle. Track the last failure per resource, show it via the
same inline error slot the OCR flow uses, and clear it on re-submission.
Android 9+ blocks cleartext traffic by default, so an http:// base URL
passes validation but every probe and download fails with a generic
network error that gives the user no clue about the cause. Show a hint
in the edit dialog when the URL is http.
The fetch guard (check-then-act on isFetching) was not atomic, so two
concurrent refresh() calls could both pass it and probe in parallel,
flickering isFetching and double-fetching.

Worse, the collector's reset (gen bump + state clear + rerunPending)
was not ordered against a refresh coroutine's publish: interleaved just
right, the reset could re-write isFetching=true after the publish
cleared it while rerunPending was set too late to be consumed, leaving
the holder stuck on isFetching=true with no fetch running.

Serialize the guard, the publish, the collector reset and the rerun
bookkeeping under one Mutex (network fetch stays outside it), and drop
the now-unneeded @volatile markers. The generation is captured in the
same critical section that raises the guard.

Extend the in-flight-refresh test to two overlapping calls.
…size

- OCR ih.db download/import and DatabaseManage download submission set
  their busy flags before dispatching to the IO dispatcher. The flag was
  previously raised inside the coroutine, so a double tap in the dispatch
  window passed the guard twice and could race two writers on the shared
  ih.db staging file.
- ImportTaskQueue rethrows CancellationException instead of logging it as
  a task failure: the log append itself throws on the already-cancelled
  coroutine, and ViewModel destruction is not a task error.
- The HEAD probe now rejects a file whose declared Content-Length exceeds
  the resource limit, surfacing the reason on the item instead of failing
  only when the download hits the streamed size bound. Covered by a new
  client test (at-limit Content-Length stays available).
@283375

283375 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

/opencode follow the new commits (starting from 657ba34). #4 is intentionally ignored and won't fix, besides that confirm the issues have been fixed, and review if there's any other problems. DO NOT read external files to avoid permission issues

@opencode-agent

opencode-agent Bot commented Sep 9, 2026

Copy link
Copy Markdown

Verdict

Follow-up commits (657ba3461dcfb3, 7 commits) are solid. I read the full range plus supporting code, verified kotlinx-io 0.9.0 move semantics from the local bytecode, ran :shared:jvmTest (passes) and :app:compileStableDebugKotlin (passes).

Issue-by-issue

#1 (atomicMove overwrite / data-loss window) — Fixed. Confirmed in the jar: kotlinx-io's NioMover calls Files.move(src, dst, ATOMIC_MOVE, REPLACE_EXISTING), and the API<26 path throws UnsupportedOperationException("Atomic move not supported"). So removing the pre-delete was right, and the renameTo fallback in atomicReplace (OcrDependenciesScreenViewModel.kt:174-185) maps to atomic rename(2) on every Android level. No gap remains; an interrupted swap leaves the old ih.db intact.

#2 (staging-path writers) — Fixed for the two UI writers, but only two of three. Manual import and remote download are now mutually exclusive via busy flags raised synchronously before dispatch (OcrDependenciesScreenViewModel.kt:125-126, 208), and 61dcfb3 closed the dispatch-window double-tap race. However, the third writer the first review named — the WorkManager ImageHashesDatabaseBuilderJob (ImageHashesDatabaseBuilderJob.kt:56-63, deletes then rewrites the final file) — is still not serialized: the OCR build button stays enabled during a download/import and the download/import buttons stay enabled during a build. Overlapping a build with an atomic swap can silently drop one result or have the builder open a freshly swapped DB and fail its DDL. Narrow, but issue #2 is only partially closed.

#3 (stale info after base-URL change) — Fixed. The collector clears info/error and bumps a generation on URL change; in-flight probes against the old URL can't publish, and the rerun is queued (RemoteResourcesInfoStateHolder.kt:52-66).

#4 (navigation cancels import) — Confirmed intentionally ignored. ImportTaskQueue still anchors tasks to the ViewModel Job, so leaving the screen cancels queued and running tasks. Acceptable per the instruction; 61dcfb3 at least keeps cancellation from being logged as a task failure.

#5 (size bound) — Fixed. 20MB enforced at probe time (HEAD Content-Length, 61dcfb3), at stream time, and for manual imports via the shared uriSizeIfTooLarge cap.

First-review nits: inline DB-manage download errors (9a31a4e), cleartext-http hint (3c22078), and the isFetching check-then-act race (c9e61b2, now mutex-serialized) are all addressed. Remaining nit: commonTest still references java.io.IOException directly — fine while only jvm/android targets exist.

Other observations

  • Residual ScoreEditor 复用问题 #2 (above) is the only substantive thing left; if build-vs-download overlap is considered out of scope, the rest of the diff is clean.
  • Minor: in DB manage, a fast double tap on the same download item can enqueue the resource twice — the VM relies on recomposition to disable the item (unlike the OCR flow, which guards in the VM). Result is duplicate queued work, not corruption (the queue serializes).
  • Minor cosmetic: while a manual refresh is in flight after a failure (info null, errorText set), the item still shows the previous refresh error under the spinner, since contentFor doesn't suppress refreshErrorText during isFetching.
  • Pre-existing, unchanged, not introduced here: the manual chart-info/st3 imports delete their cache copy only on the happy path (not in finally); the new download paths handle this correctly with finally.
  • The mutex rewrite in c9e61b2 is sound: guard, publish, reset and rerun bookkeeping are all in one critical section, fetch stays outside, and I traced the gen/rerun interleavings (URL change during fetch, refetch-after-URL-change, overlapping refreshes) — no stuck-isFetching or double-fetch paths.

New%20session%20-%202026-09-09T10%3A02%3A06.253Z
opencode session  |  github run

The download item is disabled via recomposition, which lags the click:
a fast double tap passed enqueueDownloadTask twice and enqueued the
resource twice (the busy flag itself is already set synchronously).
The serial queue keeps it from corrupting anything, but the duplicate
download and import are pure waste. Guard on set membership, matching
the OCR flow's in-VM guards.
@283375
283375 merged commit 853a633 into master Sep 9, 2026
4 checks passed
@283375
283375 deleted the feat/api-temp branch September 9, 2026 11:27
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