Skip to content

Fix cross-device sync, notifications, image proxy and the share control (v0.11.5 / android-v0.11.3) - #23

Merged
Shik3i merged 2 commits into
mainfrom
fix/sync-audit-v0.11.5
Aug 15, 2026
Merged

Fix cross-device sync, notifications, image proxy and the share control (v0.11.5 / android-v0.11.3)#23
Shik3i merged 2 commits into
mainfrom
fix/sync-audit-v0.11.5

Conversation

@Shik3i

@Shik3i Shik3i commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Full audit of the API, the web client and the Android app, with fixes for everything found. Verified end to end on an Android emulator against a local instance.

Why sync looked broken

Four independent causes, all of which read as "the app just doesn't update":

  • The web client's push died on the first rejected record. A 400 aborted the whole push, so the watermark never advanced and the same record went back up every 45 seconds — with every other local change queued behind it, forever. Pull kept working, which is why sync looked healthy. It now halves a rejected batch to isolate the offender, reports it, and lets the rest through, as Android already did.
  • The server threw away episode metadata on every playback push. Normalization replaced the payload with its own parsed struct, dropping title, artwork, podcast id, enclosure, duration and categories. The sync log is the only place that data lives, so the peer rebuilt progress rows with no title and overwrote good local ones. Unknown keys now round-trip.
  • Listening sessions could exceed the server's own ceilings (a player paused across a holiday spans more than seven days), making them permanently unpushable. Both clients clamp before sending, holding the end timestamp that last-writer comparisons key on.
  • An empty folder cleared the peer's folder. Android guarded this; the web did not. Android additionally lost updated_at on pulled subscriptions.

Why notifications never arrived

  • The Web Push Topic header was 44 characters (podcast- + UUID) against RFC 8030's 32-character cap, so push services rejected every new-episode notification. It is a hash of the podcast id now.
  • Healthy feeds were rescheduled 24 hours out, so a daily show could be announced most of a day late. Configurable via FEED_REFRESH_INTERVAL_MS, default one hour; conditional requests keep it cheap. The send loop no longer holds a SQLite read cursor across third-party network calls while deleting from the same table.
  • The Android notification was built from English literals — the one thing a listener sees without opening the app was the one thing that ignored their language. String resources now, with tests pinning both locales and the plural's format arguments.

Why cover art was missing

The image proxy sent a browser's Accept header with AVIF first. Every CDN that negotiates on it (imgix, Cloudinary, auto=format) returned AVIF; this build has no AVIF decoder, so the decode failed and the handler answered with its own placeholder at 200. Artwork that was never broken rendered as a grey rectangle, and the 200 hid it from the access log. Accept now lists only what the registered decoders can read.

Android

  • Pull-to-refresh reached only the Inbox. It now covers Library, Profile and community statistics (which sync) and Discover and a podcast (which re-read the feed). Downloads is deliberately excluded — it is device-local, and a gesture that does nothing is worse than none.
  • Returning from the background waited out the rest of the 45-second tick, and a cached process is frozen. The coordinator syncs on foreground now; the periodic tick stays ungated so screen-off listening keeps reaching the account.

Sharing

The episode share control was labelled "continue on another device" and quietly wrote to the clipboard on any browser without a native share sheet. It is a share control now: it opens, shows the exact link, offers the system share sheet where one exists and copy or email everywhere else, and lets the listener choose whether the timestamp is included. Android's button carried the same wrong name for what was already a real share sheet.

Hardening

  • A registration losing the unique-index race answers 409, not 500.
  • Recovery-code verification spends the same work as a real check, closing the username-enumeration gap login already covered.
  • The SSRF blocklist covers the NAT64 well-known prefixes, benchmarking and IETF protocol assignment ranges.

Verification

Go, web (143 tests, svelte-check clean) and Android suites all pass; translation, docs and release-policy checks pass. On the emulator against a local instance: subscriptions, folders and playback state cross over, pull-to-refresh triggers a sync, the foreground trigger fires ~460 ms after opening, the share sheet opens with the timestamped link, and cover art renders after the proxy fix.

Known, not fixed

  • The new-episode notification still uses stat_sys_download_done as its small icon. The repo's rule is not to hand-draw icon paths, and there is no suitable bell/RSS drawable in the project yet.
  • podcasts.update_frequency_ms is dead schema — the worker has never read it. Left alone rather than churn a migration.

Versions: apps/web 0.11.5, Android versionCode 43 / versionName 0.11.3. Tags follow after merge.

🤖 Generated with Claude Code

Shik3i and others added 2 commits August 15, 2026 12:20
Sync between the web client and Android was failing in ways that all looked
like "the app just doesn't update", and the audit found four independent
causes plus two that stopped notifications from arriving at all.

Sync

- The web client aborted its whole push on a 400. The watermark never moved,
  so the same rejected record went back up every 45 seconds and every other
  local change queued behind it forever, while pull kept working and made
  sync look healthy. It now halves a rejected batch to isolate the offending
  operation, reports it, and lets the rest through, as Android already did.
- The server replaced a playback_state payload with its own parsed struct,
  dropping the title, artwork, podcast id, enclosure, duration and categories
  the clients denormalize into it. The sync log is the only place that data
  lives, so the receiving device rebuilt progress rows with no title and
  overwrote good local ones. Normalization now merges into the original
  object and unknown keys round-trip.
- Listening sessions could exceed the server's own ceilings (a player paused
  across a holiday spans more than seven days), making them permanently
  unpushable. Both clients clamp before sending, holding the end timestamp
  that last-writer comparisons key on.
- An empty folder in a subscription payload cleared the folder on the peer;
  Android already guarded this, the web did not. Android additionally lost
  updated_at on pulled subscriptions.

Notifications

- The Web Push Topic header was "podcast-" plus a UUID: 44 characters against
  RFC 8030's 32-character cap, so push services rejected every new-episode
  notification. It is now a hash of the podcast id, keeping the collapsing
  behaviour within the allowed length.
- Healthy feeds were rescheduled 24 hours out, so a daily show could be
  announced most of a day late. The interval is configurable via
  FEED_REFRESH_INTERVAL_MS and defaults to an hour; conditional requests keep
  it cheap. The push send loop no longer holds a SQLite read cursor across
  third-party network calls while deleting from the same table.
- The Android new-episode notification was built from English literals, so
  German listeners got English text in the one place they see without opening
  the app. It uses string resources now, with tests pinning both locales and
  the plural's format arguments.

Android

- Pull-to-refresh reached only the Inbox. It now covers Library, Profile and
  the community statistics (which sync) and Discover and a podcast (which
  re-read the feed). Downloads is deliberately excluded: it is device-local,
  and a gesture that does nothing is worse than none.
- Returning from the background waited out the remainder of the 45-second
  tick, and a cached process is frozen, so an episode finished elsewhere
  could sit there unplayed for most of a minute. The coordinator now syncs on
  foreground; the periodic tick stays ungated so progress from screen-off
  listening keeps reaching the account.

Sharing

- The episode share control was labelled "continue on another device" and
  quietly wrote to the clipboard on any browser without a native share sheet.
  It is a share control now: it opens, shows the exact link, offers the system
  share sheet where one exists and copy or email everywhere else, and lets the
  listener choose whether the timestamp is included. Android's button carried
  the same wrong name for what was already a real share sheet.

Hardening

- A registration losing the unique-index race answers 409 rather than 500.
- Recovery-code verification spends the same work as a real check, so it
  cannot be used to enumerate usernames the way login already prevented.
- The SSRF blocklist covers the NAT64 well-known prefixes, benchmarking and
  IETF protocol assignment ranges.

Verified on an Android emulator against a local instance: subscriptions,
folders and playback state cross over, pull-to-refresh triggers a sync, the
foreground trigger fires within half a second, and the share sheet opens with
the timestamped link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The proxy sent a browser's Accept header, AVIF first. Every CDN that
negotiates on it — imgix, Cloudinary, Cloudflare Images, anything serving
`auto=format` — honoured that and returned AVIF. This build registers JPEG,
PNG, GIF and WebP decoders and no AVIF one, so the decode failed and the
handler answered with its own placeholder at 200, complete with a
Cache-Control header. Artwork that was never broken rendered as a grey
rectangle, and the 200 made it invisible in the access log.

Accept now lists only what the registered decoders can read. Confirmed
against a real imgix cover: `image/avif` before, `image/jpeg` and no
X-KoalaCast-Image-Fallback after, and the cover renders on device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Shik3i
Shik3i merged commit a885eb8 into main Aug 15, 2026
4 checks passed
@Shik3i
Shik3i deleted the fix/sync-audit-v0.11.5 branch August 15, 2026 10:43
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