Skip to content

✨ feat: F-Droid publishing readiness#4

Merged
vitofico merged 11 commits into
mainfrom
feat/fdroid-publishing
May 8, 2026
Merged

✨ feat: F-Droid publishing readiness#4
vitofico merged 11 commits into
mainfrom
feat/fdroid-publishing

Conversation

@vitofico

@vitofico vitofico commented May 8, 2026

Copy link
Copy Markdown
Owner

Summary

  • Tag-driven version derivation (buildSrc/Version.kt) replacing CI env vars — F-Droid can now reproduce a build from a tag.
  • CI workflow drops BUILD_DATE / GITHUB_RUN_NUMBER injection; reads git describe instead. Both checkout steps now use fetch-depth: 0 so tags propagate.
  • featureGraphic.png (1024×500) for the F-Droid listing.
  • Changelog filename aligned with the actual versionCode of v2026.05.08.30 (26050830.txt).
  • CONTRIBUTING.md now documents :wrench: chore: and :construction: (already used in history).
  • docs/release.md documents the local reproducibility check (fdroid build --server + content-diff).

Out of scope (deferred)

  • Tablet screenshots — listing accepts phone-only.
  • R8 / minify — separate follow-up issue planned.
  • fdroiddata MR — submitted off-repo after this merges and a fresh tag is cut.

Test plan

  • scripts/dgradle :buildSrc:test — 8 tests pass
  • scripts/dgradle :app:assembleDebug — APK builds; manifest reports the tag-derived versionCode/versionName
  • scripts/dgradle test — full suite passes
  • CI green on the PR
  • Visual: confirm featureGraphic.png renders correctly

🤖 Generated with Claude Code

vitofico and others added 11 commits May 8, 2026 11:56
Covers version-derivation rewrite (git describe-driven), fdroiddata
recipe + local verification, and the polish items needed for a clean
F-Droid listing (changelog, featureGraphic, tablet screenshots,
CONTRIBUTING tweak, repro-build doc, R8 follow-up issue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Twelve tasks covering: buildSrc Version parser (TDD), wiring into
app/build.gradle.kts, CI workflow update, changelog rename, CONTRIBUTING
gitmoji update, tablet screenshots, featureGraphic, release.md repro
section, PR creation, and the off-repo fdroiddata MR + R8 follow-up issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1024x500 PNG, paper-tone background (#F2EBDD), icon left-thirds,
"Quire" wordmark and tagline in Helvetica. Generated with ImageMagick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
actions/checkout@v4 with fetch-depth: 0 alone does not pull tags;
needs fetch-tags: true. The previous shell-step `git fetch --tags`
was apparently failing silently, so the build's gitDescribe() got
just a SHA and Version.fromGitDescribe rejected it.

Also fail-fast in the Compute version step if no v* tag is reachable,
instead of letting Gradle hit the same error 30s later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vitofico
vitofico merged commit 1be0321 into main May 8, 2026
5 checks passed
@vitofico
vitofico deleted the feat/fdroid-publishing branch May 8, 2026 11:42
vitofico added a commit that referenced this pull request May 8, 2026
…adle

PR #4 broke the release workflow: Gradle's tag-driven version derivation
expects an exact-match tag at HEAD, but on main there isn't one until
the release job creates it. Result: git describe returned the post-tag
dev format (e.g. v2026.05.08.30-1-g1be0321) and the release action used
that string verbatim as the new tag and Release name.

Fix: split the Compute version step. On refs/heads/main, compute a fresh
CalVer from date + GITHUB_RUN_NUMBER and `git tag` HEAD locally so
Gradle's git-describe finds an exact match. The release job then pushes
this tag when creating the GitHub Release. PR / feature-branch builds
keep using `git describe` for artifact-naming purposes.

The bad tag/release v2026.05.08.30-1-g1be0321 has been deleted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vitofico added a commit that referenced this pull request May 8, 2026
- Tag-driven version derivation in buildSrc/Version.kt (8 unit tests, TDD)
  replaces env-var-injected CalVer; F-Droid can now reproduce a build
  from a tag.
- CI workflow drops BUILD_DATE / GITHUB_RUN_NUMBER injection; uses
  fetch-tags: true on actions/checkout so git describe works.
- featureGraphic.png (1024x500) for the F-Droid listing.
- Changelog renamed to 26050830.txt to match v2026.05.08.30 versionCode.
- CONTRIBUTING.md documents 🔧 chore: and 🚧.
- docs/release.md documents the local fdroid build --server repro check.

Closes #5 (R8 follow-up tracked separately).
vitofico added a commit that referenced this pull request May 8, 2026
…adle

PR #4 broke the release workflow: Gradle's tag-driven version derivation
expects an exact-match tag at HEAD, but on main there isn't one until
the release job creates it. Result: git describe returned the post-tag
dev format (e.g. v2026.05.08.30-1-g1be0321) and the release action used
that string verbatim as the new tag and Release name.

Fix: split the Compute version step. On refs/heads/main, compute a fresh
CalVer from date + GITHUB_RUN_NUMBER and `git tag` HEAD locally so
Gradle's git-describe finds an exact match. The release job then pushes
this tag when creating the GitHub Release. PR / feature-branch builds
keep using `git describe` for artifact-naming purposes.

The bad tag/release v2026.05.08.30-1-g1be0321 has been deleted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vitofico added a commit that referenced this pull request May 8, 2026
- Tag-driven version derivation in buildSrc/Version.kt (8 unit tests, TDD)
  replaces env-var-injected CalVer; F-Droid can now reproduce a build
  from a tag.
- CI workflow drops BUILD_DATE / GITHUB_RUN_NUMBER injection; uses
  fetch-tags: true on actions/checkout so git describe works.
- featureGraphic.png (1024x500) for the F-Droid listing.
- Changelog renamed to 26050830.txt to match v2026.05.08.30 versionCode.
- CONTRIBUTING.md documents 🔧 chore: and 🚧.
- docs/release.md documents the local fdroid build --server repro check.

Closes #5 (R8 follow-up tracked separately).
vitofico added a commit that referenced this pull request May 8, 2026
…adle

PR #4 broke the release workflow: Gradle's tag-driven version derivation
expects an exact-match tag at HEAD, but on main there isn't one until
the release job creates it. Result: git describe returned the post-tag
dev format (e.g. v2026.05.08.30-1-g1be0321) and the release action used
that string verbatim as the new tag and Release name.

Fix: split the Compute version step. On refs/heads/main, compute a fresh
CalVer from date + GITHUB_RUN_NUMBER and `git tag` HEAD locally so
Gradle's git-describe finds an exact match. The release job then pushes
this tag when creating the GitHub Release. PR / feature-branch builds
keep using `git describe` for artifact-naming purposes.

The bad tag/release v2026.05.08.30-1-g1be0321 has been deleted.
vitofico added a commit that referenced this pull request May 8, 2026
Post-PR #4 follow-up that consolidates several CI / build fixes into one
squashed commit:

- **JDK 17 → 21** across all module build files, both CI workflows, and
  the Docker base image. Matches F-Droid's buildserver and removes the
  Kotlin/Java JVM-target mismatch we hit on first submission.
- **Main-branch CalVer tagging in CI** — the build job now computes a
  fresh \`vYYYY.MM.DD.<run>\` tag locally before Gradle runs so its
  git-describe finds an exact match. Previously the release action
  tagged with the literal git-describe output (\`v2026.05.08.30-1-g1be0321\`).
- **Release job version pass-through** — release runner gets a fresh
  checkout with no local tag, so feed the build job's computed version
  in via \`QUIRE_VERSION_FALLBACK\`.
- **CodeQL fallback** — same fallback for the analyze workflow, plus
  \`fetch-tags: true\` on its checkout.
- **Disable AGP 8.3+ VCS-info embedding** in the release buildType so
  F-Droid's reproducible-build verification has a chance.
vitofico added a commit that referenced this pull request May 20, 2026
* ✨ feat(server): add env-prefix back-compat helper

Centralize QUIRE_SERVER_* / OPDS_SYNC_* precedence logic per coordinator
§3.9. New prefix wins; legacy prefix triggers a one-shot WARNING per
process per key. Per Lock #21, only os.environ is consulted (dotenv
source not routed through the helper).

Helper lives under the still-current opds_sync package; subsequent commit
renames the package to quire_server and the helper moves with it.

* ✨ feat(server): wire Settings to dual-prefix env source

Replace the default env source with LegacyEnvSettingsSource so each field
is resolved from QUIRE_SERVER_* with one-cycle fallback to OPDS_SYNC_*.
The dotenv source still uses env_prefix="QUIRE_SERVER_" (Lock #21).

Switch test_settings primary cases to the new prefix and keep one
back-compat test; add test_settings_env_compat covering precedence,
WARNING emission, and complex-field (ai_token_secrets) round-trip under
both prefixes.

* ✨ feat(server): migrate.py reads QUIRE_SERVER_* with legacy fallback

main() now resolves PROGRESS_ENABLED / AI_ENABLED through
resolve_env_prefix_value, so deployments can use either prefix during the
one-cycle back-compat window. Adds test_migrate_env_compat covering both
prefixes plus the both-set tiebreaker.

* 🔨 refactor(server): rename Python package opds_sync -> quire_server

Mechanical commit:
- git mv server/opds_sync -> server/quire_server
- Rewrite every import (server source, tests, scripts/migrate, alembic env)
- Switch test setenv calls from OPDS_SYNC_* to QUIRE_SERVER_*
  (legacy prefix still honored via _env_compat; one defensive
  back-compat case retained in test_settings)
- Add tests/unit/test_no_stale_opds_sync_imports.py for fast local
  feedback if a stale import slips through

DB role / DB name / volume name 'opds_sync' are a deliberate non-rename
per Lock #20; the alembic.ini sqlalchemy.url default and the config.py
database_url default explicitly preserve 'opds_sync' as the DB name.

* 🔨 refactor(server): rename packaging/runtime metadata to quire-server

- pyproject.toml: project name + setuptools include + marker descriptions
- Dockerfile: OCI labels + entrypoint comment refers to QUIRE_SERVER_*
- docker-compose.yml / docker-compose.full.yml: service name and image
  switch to quire-server; env vars promoted to QUIRE_SERVER_* with
  ${QUIRE_SERVER_X:-${OPDS_SYNC_X:-default}} fallbacks so the back-compat
  window also covers operators who pass values through Compose
- caddy/Caddyfile: upstream renamed to quire-server:8000
- .env.example: every key prefixed QUIRE_SERVER_*; banner explains Lock
  #21 dotenv limitation

The DB role / DB name / Postgres volume (postgres user/db 'opds_sync',
volume 'opds_sync_pg') stay unchanged per Lock #20; alembic.ini and
config.py preserve the same DB name in their defaults.

* ✨ ci(server): dual-publish image and add rename-guard step

server-ci.yaml:
- Mode matrix env vars renamed to QUIRE_SERVER_*
- Image build now produces both ghcr.io/.../quire-server and the legacy
  ghcr.io/.../opds-sync tags from the same digest, so operators have a
  one-cycle window to repin
- Trivy scan targets the new image name
- Adds a rename-guard regex (catches both opds-sync / opds_sync as
  standalone tokens and OPDS_SYNC_<X> env vars) with an explicit
  self-test fixture before it runs
- Allowlist matches Lock #20 plus three defensive entries
  (env-compat tests + tests/conftest.py + import-graph regression test)

android-ci.yaml:
- Mirrors the same self-test + rename-guard step. The server CI is
  path-filtered to server/**, so a stale legacy reference added in an
  Android-side commit would slip past server-ci entirely (Required #5).

* 📝 docs(server): rename opds-sync references and document migration

- Rename Python module / image / service references across docs/,
  README.md (active prose, with the historical sentence preserved per
  the Lock #20 allowlist note), CONTRIBUTING.md, SECURITY.md, NOTICE,
  fastlane Play Store copy, and Android KDocs that point at
  server-side files.
- server/README.md gains a 'Migration from opds-sync' section covering
  the dual-prefix env helper, the dual-published image tag, and the
  deliberate DB-name non-rename. Also adds the five previously-omitted
  env vars (CWA_PROBE_PATH / CWA_PROBE_TIMEOUT_S / AUTH_CACHE_MAX_ENTRIES
  / AUTH_CACHE_POSITIVE_TTL_S already present) and an
  AI_PROMPT_VERSION row documenting the Lock #19 sentinel.
- docs/release.md gains the 'Post-release cluster operator checklist'
  paragraph pointing at the operational ticket flow (Lock #4); the
  Quire repo does not modify any cluster manifests.
- Update the rename-guard allowlist (server-ci + android-ci) to cover
  docs/release.md, docs/development.md, server/quire_server/config.py
  (DB-name preservation) and server/migrations/env.py (same).
- Server-source docstrings, error messages, User-Agent strings, and the
  FastAPI title swap to QUIRE_SERVER_* / quire-server. Token-mode
  validation error messages now reference QUIRE_SERVER_AI_*; the unit
  tests assert on the suffix (AI_TOKEN_SECRETS / _ISSUER / _AUDIENCE)
  so they remain agnostic to the prefix change.

* 💚 ci: install ripgrep before the rename-guard step

GitHub-hosted ubuntu-latest runners do not ship with ripgrep by default,
so both the server-ci 'full' matrix cell and the android-ci 'build' job
failed with 'rg: command not found' on the self-test step. Install via
apt before the guard runs.
vitofico added a commit that referenced this pull request May 21, 2026
…le (pr-γ)

PR-γ of Bundle 3 (Reader Profile) — adds the user-visible Reader Profile
surface on Android. New "Insights" entry on the Library overflow menu
opens LibraryInsightsScreen, which renders five sealed UI states
(Disabled / Empty / Loading / Loaded / Error) over GET /ai/v1/profile and
POST /ai/v1/profile/refresh.

Highlights:

* AiClient gains refreshProfile() (200 → ReaderProfileResponseDto, 409
  ai_not_opted_in, 429 quota, 5xx model failure) and deleteProfile()
  (idempotent 204 — wired by PR-δ from Settings).
* AiRepository.refreshProfile drains the sync orchestrator + library
  uploader best-effort before calling the server (Lock #6 NTH #9;
  corrections.md PR-γ Required #6). Preflight failures log via the
  PreflightOutcome callback and the refresh proceeds regardless.
* Decoupling via two fun interfaces (ProfilePreflightSync /
  ProfilePreflightLibrary) so :data:ai never depends on :data:sync or
  :data:library.
* AiConfig.progressSupported (Lock #5) consumed to drive
  Disabled.ProgressUnsupported; the server already advertises the field.
* Local input-fingerprint approximation per Lock #12 (mirrors PR-β's
  server recipe; uses ReaderStats.booksWithThemesCount per Lock #15 / CC-8,
  NOT topThemes.size). Mismatch renders a low-emphasis "Profile may be
  out of date" hint (architect Finding #2).
* In-library recommendations resolve via DocumentRepository.findByIdentity
  → /reader/{id}; discovery recommendations expose an explicit "Open in
  browser" button gated on https:// (architect Finding #4), wrapped in
  runCatching for ActivityNotFoundException.
* Coverage meter uses booksWithThemesCount/totalBooks (NTH #12, Lock #15).
* AppNavGraph wires composable("library/insights"); AppContainer adds
  LibraryInsightsViewModelFactory + a StateFlow<Boolean> over
  AiPreferences.aiEnabled.

Tests cover the disabled/empty/loaded/error state transitions, the local
fingerprint recipe (match + mismatch + null cases), and best-effort
preflight semantics (sync→library→refresh order, network still fires when
preflight throws). All :app:testDebugUnitTest and :data:ai:testDebugUnitTest
pass.
vitofico added a commit that referenced this pull request May 21, 2026
…-β + PR-γ + PR-δ) (#34)

* ✨ feat(server): reader_profiles substrate + abandoned_at + ReaderStats v1 + GET /ai/v1/profile

Lands the server half of pr-α (Bundle 3 / Reader Profile substrate):

* Migrations
  - progress_002: add `progress.abandoned_at` + check constraint
    `finished_at IS NULL OR abandoned_at IS NULL` + partial index on
    `(document_pk) WHERE abandoned_at IS NOT NULL`.
  - ai_005: new `reader_profiles` table keyed on `(tenant_id, subject)`
    with `payload JSONB`, `schema_version`, `model_id`, `prompt_version`,
    `input_fingerprint VARCHAR(16)` (Lock #12 / coordinator §3.6).

* ORM
  - `Progress.abandoned_at` + `ck_progress_abandoned_xor_finished`.
  - New `ReaderProfile` model — user-scoped, NOT shared cache.

* Pydantic
  - `AuthorCount`, `ReaderStats` (with `books_with_themes_count: int = 0`
    per Lock #15 / CC-8), `BookRec` (full §3.4 provenance shape with
    safe defaults), `ReaderProfilePayload` (schema_version=1), and the
    `ReaderProfileResponse` envelope.

* `_compute_reader_stats` (`quire_server.core.ai.service`)
  - Deterministic per-user stats: total/finished/in-progress/abandoned
    counts, `finish_rate_by_theme` (top-10), `most_read_authors`
    (top-5).
  - Mirrors PR9's `pick_priority = case(...)` pattern exactly
    (coordinator §3.4 REJECT (e)).
  - pr-α emits `books_with_themes_count = 0`; pr-β replaces with the
    real count.

* Terminal-state invariant
  - `push_progress` clears the opposite terminal flag on a flip;
    preserves `percent` on the abandon transition.
  - `pull_progress` drops `abandoned_at` (finished wins) for corrupt
    legacy rows and emits a structured warning.

* `GET /ai/v1/profile`
  - Cache-only read. No opt-in gate (opted-out users can still read
    their last generation). 404 when no row exists. Refresh / LLM
    generation lives in pr-β.

* Tests (server/tests/integration/test_reader_profile.py + adjusted
  test_health / test_migrate_script / test_readyz_migration_state for
  the new ai_005 + progress_002 heads).

* ✨ feat(android): abandoned_at progress field, MIGRATION_7_8, profile fetch + abandon DAO

Lands the Android half of pr-α (Bundle 3 / Reader Profile substrate):

* Room
  - DB version 7 -> 8 + MIGRATION_7_8 adds nullable `abandonedAt INTEGER`
    column to `progress`.
  - `ProgressEntity.abandonedAt: Long?` (Long? maps to nullable INTEGER).
  - `ProgressDao.markAbandoned` / `unmarkAbandoned` bump BOTH `updatedAt`
    AND `localUpdatedAt` to `now`. This is load-bearing: Android pushes
    `progress.updatedAt` as `client_updated_at`, and the server's LWW
    guard only accepts the row when `client_updated_at` is strictly
    newer. Setting only `localUpdatedAt` would make the row "dirty"
    locally but the server would silently reject the abandon.
  - `markAbandoned` clears `finishedAt`; `unmarkAbandoned` leaves
    `percent` untouched. (Abandoning at 60% remembers 60%.)

* Core / sync DTO
  - `Progress.abandonedAt` on the domain model so the push path can
    read it through ProgressRepository.dirty().
  - `ProgressItemDto.abandonedAt` (snake_case wire name).
  - `SyncOrchestrator`: push serializes `abandonedAt` as ISO-8601;
    pull defensively drops `abandonedAt` if `finishedAt` is also set
    (finished wins) — mirrors the server's defensive read.

* AI
  - `AiClient.fetchProfile()` -> `GET /ai/v1/profile`. Maps 404 to null.
  - `AiRepository.fetchProfile()`, `markAbandoned()`, `unmarkAbandoned()`.
    Constructor gains an optional `progressDao` so existing test sites
    that only stub `insightDao` keep compiling; AppContainer wires the
    real DAO.
  - DTOs for `ReaderProfileResponse`, `ReaderProfilePayload`,
    `ReaderStats` (including `booksWithThemesCount` per Lock #15 / CC-8),
    `BookRec` (full coordinator §3.4 shape), `AuthorCount`.
  - `source_type`, `owned_state`, `confidence` declared as `String`
    (not enums) so a future server bump doesn't crash existing clients.

* Tests
  - MigrationTest: `migrate 7 to 8 adds abandonedAt column`,
    `migrate 6 to 8 chains via book_insights and abandonedAt` (the
    Lock #8 chained-migration test).
  - ProgressDaoTest: mark/unmark abandon semantics + LWW timestamp
    correctness (BOTH updatedAt AND localUpdatedAt bumped) + dirty()
    pickup.
  - ProgressDtosTest: round-trip + null-omission for `abandonedAt`.
  - SyncOrchestratorTest: defensive-pull rule when server emits both
    terminal flags.
  - AiRepositoryProfileTest: fetchProfile 404/200, mark/unmark
    delegation through FakeProgressDao.

* ✨ feat(server): refresh_profile orchestrator + POST /ai/v1/profile/refresh (pr-β)

Stitches the AI building blocks into a per-user reader-profile orchestrator
gated on AI_ENABLED + PROGRESS_ENABLED + opt-in:

- ai_006 migration: ai_generation_log.book_insight_id becomes nullable,
  new `kind` discriminator with CHECK admitting 'insight'/'profile'/'promote'
  (Lock #11 amendment), partial index for profile-row lookups, and
  ai_usage_daily.profile_count for the 3/day cap.
- refresh_profile(): per-user singleflight lock, low-data short-circuit
  with weight=0 (runs even at quota cap), daily-cap enforcement, global
  semaphore around the LLM call, deterministic candidate maps for in-library
  + OpenLibrary-discovery recommendations, owner-exclusion belt-and-suspenders,
  16-hex-char input_fingerprint, kind='profile' audit rows per waiter
  (including collapsed singleflight waiters).
- PR-ζ wire-up (Lock #11 amendment): promote handler now writes a
  kind='promote' DB row alongside the retained stdout audit line.
- Retriever.author_bibliography(): 30d positive cache, 24h negative cache,
  429 with Retry-After capped at 6h, 5xx → stale-if-error via new
  allow_stale=True read path.
- _compute_reader_stats now populates books_with_themes_count (Lock #15).
- ConfigResponse gains progress_supported so AI-only deploys can suppress
  the Android reader-profile UI (coordinator §3.5).
- READER_PROFILE_PROMPT + READER_PROFILE_PROMPT_VERSION='1' bumped
  independently of the per-book PROMPT_VERSION.
- Tests: existing audit-log / migration / promote / health tests updated
  for the ai_006 schema (kind required, head moves ai_005 → ai_006).
  Integration test_reader_profile passes all 11 cases.
- docs/sync-api.md: full POST /profile/refresh section + progress_supported
  on GET /ai/v1/config.

426 tests pass.

* ✨ feat(android): Library Insights screen + refreshProfile/deleteProfile (pr-γ)

PR-γ of Bundle 3 (Reader Profile) — adds the user-visible Reader Profile
surface on Android. New "Insights" entry on the Library overflow menu
opens LibraryInsightsScreen, which renders five sealed UI states
(Disabled / Empty / Loading / Loaded / Error) over GET /ai/v1/profile and
POST /ai/v1/profile/refresh.

Highlights:

* AiClient gains refreshProfile() (200 → ReaderProfileResponseDto, 409
  ai_not_opted_in, 429 quota, 5xx model failure) and deleteProfile()
  (idempotent 204 — wired by PR-δ from Settings).
* AiRepository.refreshProfile drains the sync orchestrator + library
  uploader best-effort before calling the server (Lock #6 NTH #9;
  corrections.md PR-γ Required #6). Preflight failures log via the
  PreflightOutcome callback and the refresh proceeds regardless.
* Decoupling via two fun interfaces (ProfilePreflightSync /
  ProfilePreflightLibrary) so :data:ai never depends on :data:sync or
  :data:library.
* AiConfig.progressSupported (Lock #5) consumed to drive
  Disabled.ProgressUnsupported; the server already advertises the field.
* Local input-fingerprint approximation per Lock #12 (mirrors PR-β's
  server recipe; uses ReaderStats.booksWithThemesCount per Lock #15 / CC-8,
  NOT topThemes.size). Mismatch renders a low-emphasis "Profile may be
  out of date" hint (architect Finding #2).
* In-library recommendations resolve via DocumentRepository.findByIdentity
  → /reader/{id}; discovery recommendations expose an explicit "Open in
  browser" button gated on https:// (architect Finding #4), wrapped in
  runCatching for ActivityNotFoundException.
* Coverage meter uses booksWithThemesCount/totalBooks (NTH #12, Lock #15).
* AppNavGraph wires composable("library/insights"); AppContainer adds
  LibraryInsightsViewModelFactory + a StateFlow<Boolean> over
  AiPreferences.aiEnabled.

Tests cover the disabled/empty/loaded/error state transitions, the local
fingerprint recipe (match + mismatch + null cases), and best-effort
preflight semantics (sync→library→refresh order, network still fires when
preflight throws). All :app:testDebugUnitTest and :data:ai:testDebugUnitTest
pass.

* ✨ feat(pr-δ): DELETE /ai/v1/profile + abandoned UX + delete-profile button

Server:
- DELETE /ai/v1/profile (idempotent, 204) gated on ai_enabled + opt-in
- 5 integration tests covering happy path, idempotency, tenant isolation,
  not-opted-in (409), and ai_disabled (503)

Android:
- LibraryPreferences struct (sort + showAbandoned), persisted via the
  same SharedPreferences file (Lock #17); migration is read-only — old
  key 'library_sort' keeps working.
- Library home gains a 'Show abandoned' toggle; abandoned books are
  hidden by default.
- Settings: 'Delete reader profile' button calls AiRepository.deleteProfile()
  with snackbar feedback (success / failure) and a re-entrancy guard.
- ProgressRepository: expose abandonedAt for the library filter.

* ✨ fix(server): POST /profile/refresh returns the ReaderProfileResponse envelope

`POST /ai/v1/profile/refresh` used to return the raw `ReaderProfilePayload`
while the Android client decodes the wrapped `ReaderProfileResponseDto`
envelope — every real refresh would have failed client-side
deserialization. The bug was masked in `AiRepositoryRefreshTest` because
the fixture body already used the envelope shape.

Refresh now returns the same envelope as `GET /profile` so the server
is internally consistent and the Android client decodes one DTO for
both endpoints. Added an integration test that exercises the low-data
short-circuit path (no LLM call required) and asserts the envelope
top-level fields are present.

* ✨ fix: canonicalize reader profile fingerprint as epoch millis

Server hashed `datetime.isoformat()` (UTC → `+00:00`) while Android
hashed `Instant.toString()` (UTC → `Z`). The two representations
diverge for the same instant, so the fingerprint compare reported
spurious staleness on every refresh and the "Profile may be out of
date" banner would have been permanently on.

Both ends now serialize `latest_progress_updated_at` as epoch
milliseconds (or the literal `"none"` when absent) before hashing.
Docstrings on both sides call out the contract so a future change on
either side cannot drift again. Existing VM fingerprint test updated
to the canonical token and a second test covers the no-progress path.

* ✨ feat(android): surface ai_suggested_recommendations in Library Insights

The server `ReaderProfilePayload` carries an `ai_suggested_recommendations`
list alongside the in-library and discovery buckets, but the Android
DTO didn't declare the field and the screen never rendered the bucket —
so AI-only suggestions (no candidate match, no Open Library URL) were
silently dropped from the UI.

Added the field to `ReaderProfilePayloadDto` (defaulted, so older
server builds round-trip cleanly) and added a "Suggested by AI"
LazyRow section between Discovery and the footer. `BookRecCard`
already handles `source_type == "ai_suggested"` by showing
title/author/rationale with no link affordance, so no card-level
changes were needed.
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