Skip to content

The Android app

hypercal ships as a standalone Android app: it carries its own backend and its own database, and works with no server at all. The same app can instead be pointed at a self-hosted hypercal server, chosen on first launch.

How it works

The frontend is unchanged. What changes is where /api/... goes.

┌─ main thread ─────────────────┐        ┌─ Web Worker ──────────────────────┐
│ React UI                      │        │ createApp({ db })   ← core/app.ts  │
│ lib/api.ts → apiFetch()       │ ─msg─► │ app.fetch(Request)                 │
│   local  → standalone/bridge  │ ◄─msg─ │ drizzle(sql-js adapter)            │
│   remote → the network        │        │ @sqlite.org/sqlite-wasm on OPFS    │
└───────────────────────────────┘        └────────────────────────────────────┘

The backend is not reimplemented for the client. web/src/standalone/backend/ runs core/src verbatim — the same Hono routes, the same Drizzle schema, the same migrations — against WASM SQLite instead of Node and better-sqlite3. The route tests in core/src/**/*.test.ts therefore exercise the app's backend too, though not the shims, the bridge or the OPFS/WASM layer below it.

web/src/standalone/ is laid out by what the code needs, not by feature: backend/ is the worker and its shims (no Capacitor anywhere), native/ is the only part that touches Capacitor plugins, and bridge.ts/mode.ts/protocol.ts are the transport between them.

The worker is not optional: OPFS sync access handles, the only way to get synchronous and durably-persisted SQLite in a browser, exist only off the main thread. It also keeps the backend and a WASM module off the UI thread, which leaves it free for gestures.

web/src/standalone/backend/shims/ holds browser stand-ins for what core/src imports from Node — each file explains what it does and does not provide. Two are worth knowing about because they are platform limits rather than conveniences:

  • hono-cookie.ts — the Fetch spec makes Set-Cookie a forbidden response-header name and Cookie a forbidden request-header name, so a browser Response/Request cannot carry either. Cookies travel over private header names instead, with the jar in standalone/bridge.ts.
  • ssrf.ts — the real module DNS-pins outbound feed URLs to stop a hosted server being used as a proxy. On a device there is no privilege to escalate, so the check is removed rather than faked; the redirect cap and timeout stay.

Building

sh
npm run dev:local          # the app in a desktop browser, no server running
npm run build:android      # standalone bundle + `cap sync android`
npm run test:local         # Playwright against the dev server
npm run test:local:dist    # Playwright against the built bundle + bundle checks
npm run build:sqlite-wasm  # SQLite -> WASM from source; needs emscripten

Only the last one needs a toolchain beyond Node. Day-to-day work does not run it: the build falls back to the WASM in the @sqlite.org/sqlite-wasm package when there is nothing compiled. CI and F-Droid do run it, and the APK ships what it produces — see F-Droid for why that matters.

Run test:local:dist before cutting an APK. It serves vite build output rather than the dev server, which is the only way to catch problems that exist solely in the production bundle — shims that fail to resolve, tree-shaking that drops something, or public/ assets riding along. It also asserts directly on what shipped: no speech model, no service worker, and a total size budget.

npm run build:android sets HYPERCAL_TARGET=android, which:

  • bundles the backend worker and its shims;
  • drops the service worker (a packaged app already has its assets, and a second update path would fight the store's);
  • drops voice control (see below).

An APK then needs the Android SDK:

sh
cd android && ./gradlew assembleRelease

Releases: the APK CI builds

Pushing a v* tag builds the APK in the build-apk job, the Android counterpart of build-image. It installs the SDK (platform 36, build-tools 36.0.0 — keep these in step with android/variables.gradle), runs npm run build:android, assembles the release, and publishes it to the generic package registry under the package name hypercal-android, as hyper-calendar-<version>.apk. The GitLab Release then links it as an asset next to the container image.

The project's own F-Droid repository is built from that registry, not from this job's artifact — see scripts/fetch-published-apks.py for why, and note it also reads the older hypercal package name, which is where 2.0.0 and 2.0.1 were uploaded by hand before this job existed.

It goes to the package registry rather than staying a job artifact because artifacts sit behind builds_access_level, which is members-only on this project — a release asset pointing at one would 404 for everybody else. The package registry is public.

Release signing in CI

The job fails if it cannot sign. app/build.gradle treats a missing keystore.properties as "build unsigned", which is correct for F-Droid — it builds from source and signs with its own key — but an unsigned APK cannot be installed, so publishing one as a release asset would ship something no user can use.

Protected CI/CD variables, set once under Settings → CI/CD → Variables:

VariableWhat it holds
ANDROID_KEYSTORE_BASE64the keystore itself, base64, masked
ANDROID_KEYSTORE_PASSWORDstorePassword, masked
ANDROID_KEY_ALIASkeyAlias
ANDROID_KEY_PASSWORDkeyPassword, masked
ANDROID_CERT_SHA256signing certificate fingerprint — not secret

ANDROID_CERT_SHA256 pins which certificate the F-Droid publishing job will accept. That job stages every APK from the package registry and republishes it under the repository's index key, so without a pin it would vouch for whatever happens to be in the registry — including an APK someone else uploaded. It is a public value, so it needs neither masking nor protecting.

Read it from an APK you have already published:

bash
apksigner verify --print-certs app-release.apk | grep 'SHA-256 digest'

Leave it unset and the job still refuses to publish a repository whose APKs disagree on a certificate, and prints the fingerprint so you can pin it — but pinning is what turns that into a real check.

Produce the first with base64 -w0 < /path/to/release.jks and paste it — do not commit the keystore or pipe it through anything that logs. Mark all four Protected so only tag and protected-branch pipelines can read them, and the three secrets Masked so an accidental echo is redacted. The job decodes the keystore to a path outside the working tree and writes keystore.properties (gitignored) pointing at it with an absolute path.

This is not the F-Droid repository's index key, which signs the index of channel 1 and nothing else. It is, however, the key F-Droid itself will distribute under: the submission carries Binaries: and AllowedAPKSigningKeys:, so F-Droid verifies its own build against the APK this key signed and then publishes that file rather than re-signing it. One key across all three channels, and no uninstall to move between them. That only holds while the build stays reproducible, which is why this key matters more than it would otherwise. That is normal for dual distribution, but say so wherever both are offered.

Device calendars

The app can import the calendars already on the phone — Google, Exchange, or a purely local account — through Android's CalendarContract. They appear under On this device in the sidebar, grouped by account, and their events behave like any other: in every view, search, free/busy, .ics export.

The permission (READ_CALENDAR / WRITE_CALENDAR) is asked for only when someone adds one, and nothing is read before that. WRITE_CALENDAR is what lets an edit made here reach the phone's own calendar app — see the third point below. Imported events go into the app's own database and stay on the device unless that calendar is separately linked to a CalDAV server the user chose.

CalendarContract  ⇄  DeviceCalendarsPlugin.java   (android/app/src/main/java/…)
      ↕  standalone/native/deviceCalendars.ts     (main thread — Capacitor lives here)
      ↕  postMessage, like OutboundFetch
core/src/device/  ⇄  the app's SQLite             (mapping + reconcile, in the worker)
      ↕  the existing CalDAV client, per calendar
Nextcloud / a hypercal server

The reconciler is core/src/device/sync.ts, and is deliberately the same shape as the CalDAV client: gather state, ask decide() for a verdict, carry it out. decide() takes nothing but { present, etag } from the far side and has no idea whether HTTP is involved, so it is reused unchanged — a content hash of the provider's row stands in for the ETag the provider doesn't offer.

Three things worth knowing:

  • Ordering. The device pass runs before the CalDAV pass on each tick. A change made in the phone's own calendar app therefore reaches a linked remote in the same tick; a change arriving from that remote reaches the phone on the next one. Both converge, one direction is a beat slower.
  • Reminders are opt-in per calendar, off by default. Android's calendar app already notifies for these events, and two apps buzzing for one meeting is worse than neither — the user can't tell which to silence.
  • Edits made here can go back to the phone. For a calendar the provider lets us write (CAL_ACCESS_CONTRIBUTOR and above), an event created, moved or deleted in the app is written back into the phone's own calendar, so the two do not drift. Below that level — a subscribed holiday feed, typically — the provider refuses writes, and the calendar is imported read-only rather than accepting edits that would vanish on the next pass.

core/src/device/mapping.ts carries the model differences, each of which corrupts data silently rather than loudly: the provider puts all-day boundaries at UTC midnight where we store local midnight in the event's own zone; recurring events must carry DURATION and not DTEND; UID_2445 is null for anything a sync adapter didn't write, so the link is the provider row id.

"Connect to a server" mode

Requests go through Capacitor's native HTTP stack, not the WebView's fetch. That is not an optimisation: the app runs on the https://localhost origin, so every call to a real instance is cross-origin, and a browser request would be blocked by CORS (the server sends no CORS headers) and stripped of its SameSite=Lax session cookie. CapacitorHttp is enabled in capacitor.config.ts so the patch applies to window.fetch — the only route that also supports the FormData bodies avatar, attachment and .ics uploads need.

The API has to be reachable directly. If an SSO portal (Authelia, Authentik, oauth2-proxy) sits in front of the deployment, it intercepts /api/..., redirects to its login page, and hypercal receives HTML where it expects JSON — there is no way for the app to complete a portal login. hypercal authenticates by itself (passwords, passkeys, optional TOTP, scrypt hashes, HttpOnly sessions), which is what the top-level architecture notes mean by "Traefik only terminates TLS". Give the hypercal host a bypass rule.

lib/api.ts detects this specifically: a 200 whose content-type is not JSON reports that the server answered with a web page, rather than surfacing as an unexplained parse error.

What differs from the hosted app

AreaStandalone behaviour
RemindersScheduled as Android notifications ~14 days ahead and refreshed on resume, instead of Web Push. Same due-time logic (core/src/lib/reminderScheduler.ts).
Calendar feedsFetched through the native HTTP stack, which the WebView's CORS rules would otherwise block. The poller runs on the same 5-minute timer as the server's, but the WebView is frozen while the app is backgrounded — so a pass is also forced whenever the app becomes active again.
CalDAV syncWorks the same as on the server, over the same native HTTP path. The credential key has no environment to come from, so one is generated on first use and kept in app_settings — which protects an exported database, not someone holding the app's private storage.
Server backupsThe Settings list stays empty: those are files on a server's disk. Export/import is the backup story.
CalDAV server (/dav)The routes are present and work in server mode, but nothing on the device can reach them — an in-page backend has no port to listen on. This is the opposite direction from the CalDAV sync row above, which does work.
Sharing, attendees, invitationsWork between multiple accounts registered on the same device; across devices they need server mode.
Voice controlNot included — see below.
Device calendarsAndroid only, and only in standalone mode — see above. The hosted app has no calendar provider to read.
Android backupDisabled in the manifest, so the calendar and key material are never copied to a Google account.

Voice control is excluded

The recognizer needs a ~41 MB Vosk speech model that is downloaded separately and deliberately not committed (web/public/models/vosk/README.md). F-Droid builds from source on its own infrastructure and will not fetch binaries, so the model cannot be present in a release and the feature cannot work. The standalone build therefore aliases vosk-browser to a stub and hides the mic button and its setting, rather than shipping a feature that always fails.

F-Droid

There are two F-Droid channels, and they are not the same thing.

1. The project's own repository (live)

Built and published by CI to https://hyper-calendar-7b58b2.gitlab.io/fdroid/repo on every tag. It indexes the APKs build-apk uploads — signed with the project's release key — so users get update notifications without waiting on anyone. The "add this repo" instructions — including a scannable QR code carrying the repository URL and its fingerprint — are on the Install on Android page. That QR is rendered by the pages CI job rather than committed, because the fingerprint is only read out of the keystore at publish time.

The repository index is signed with a separate key from the APKs (FDROID_KEYSTORE_BASE64, not ANDROID_KEYSTORE_BASE64). F-Droid does not require them to match, and keeping them apart limits what a CI compromise can do. Clients pin the index key when they add the repository, so it must never change.

2. The official F-Droid repository (submitted)

fdroid/dev.hypercalendar.app.yml is the build recipe, kept here alongside the source; the copy F-Droid uses lives in the fdroiddata repository. F-Droid builds from source on its own infrastructure, then compares that build against the APK published here and distributes ours if the two match. So both channels carry the same signed file and Android upgrades between them normally — see Reproducible builds below for what that costs.

Status: submitted, pipeline green, awaiting merge

fdroiddata!48441 adds metadata/dev.hypercalendar.app.yml for versionCode 6. Their whole pipeline is green on it, fdroid build included, and their build produced an APK from 1c0da59, which is the commit v2.1.0 points at. The scanignore matched exactly one file, the SQLite compiled during that build:

INFO: Ignoring WebAssembly binary file at .../assets/sqlite3-IpSl6xzR.wasm
INFO: Successfully built dev.hypercalendar.app:6

A reviewer asked for two changes on 2026-09-10. Both are made in the merge request and mirrored back into fdroid/dev.hypercalendar.app.yml:

  • commit: must be a full hash, never a tag or a branch. A tag can be repointed after review; a hash cannot. It now reads 1c0da59d847a79aa28c3e6375af9337b3767a4e1, which is exactly what v2.1.0 points at, so nothing about what gets built changed. This also costs nothing later: fdroid checkupdates resolves the tag it finds through vcs.getref before writing the new build, so the bot's version bumps carry hashes too.

  • Node has to come from Debian. The recipe used to fetch the official linux-x64 tarball from nodejs.org, pinned by sha256, because the buildserver runs trixie, which ships node 20, and this repository requires >= 22. Debian forky packages 24.19, so the recipe adds forky as an extra apt source and installs nodejs npm with -t forky, which is what other Capacitor apps in fdroiddata already do. Two details in that block are not decoration:

    • The forky source is pinned to priority 100. Added plainly it lands at 500, the same as every other source, and apt then prefers the highest version it can see for every package. That quietly pulls emscripten from trixie's 3.1.69 up to forky's 6.0.5, swapping the compiler that produces the one binary in the APK. Priority 100 keeps forky invisible until something names it, which the -t forky does. Verified in a debian:trixie container: with the pin, node -v is 24.19.0 and emcc --version is 3.1.69; without it, emcc is 6.0.5.

    • Node is installed before the rest of the toolchain, because emscripten only requires nodejs (>= 12). Having node there already satisfies it, so trixie's node 20 is never installed beside it.

    • libc6-dev is installed from forky alongside node, added on 2026-09-16. -t forky makes apt take the whole dependency closure from forky, libc6 included. Trixie's libc6-dev holds itself to the older libc6, so it stops being installable and takes build-essential with it: Depends: libc6 (= 2.41-12+deb13u4) but 2.43-5 is to be installed. Pulling the dev half from forky in the same transaction keeps the two at one version, and widens nothing, since libc6 was coming either way.

      Worth knowing for what it says about the recipe in general: this block built versionCode 7 green a few days before it broke. Nothing in the recipe changed. forky moved. An apt line that names a rolling suite is a dependency on a moving target, so re-run the container build below before trusting the recipe, not only after editing it.

    xz-utils went with the tarball; curl and unzip stay, since scripts/build-sqlite-wasm.sh needs both.

A second reviewer then asked for Binaries: and AllowedAPKSigningKeys:, making the point that the choice cannot be deferred: once an app is published under F-Droid's key, moving it to ours later changes the signature, and Android will not upgrade across that. See Reproducible builds.

Their build machinery agreed the app reproduces, and then rejected the published APK for carrying AGP's dependency-metadata block. That is the whole reason 2.1.1 exists; versionCode 6 could not be fixed, because a published APK cannot be changed after the fact. The recipe now builds versionCode 7 from 28671a1, and their full pipeline is green on it:

INFO: Successfully built dev.hypercalendar.app:7 from 28671a13e0bab7bb1174995bee8bdbcc1811a4a9
INFO: compared built binary to supplied reference binary successfully
INFO: supplied reference binary has allowed signer 6dbe5c8ec768ff0cf409247f08717a6d42ab87aa4c9690ea626be3f28024589d

linsui then asked for one more thing on 2026-09-15: move the build into build: and drop the scanignore. Both are done, and the recipe got simpler rather than more involved, because a change upstream had already removed the reason it was written the other way.

fdroidserver runs prebuild:, then its source scan, then build:. Run everything in prebuild:, as this recipe did, and the scanner inspects a tree that has already been built, which is what made a scanignore for the compiled sqlite3-*.wasm necessary at all. What forced that shape was gradle clean, which used to run between prebuild: and the scan and cannot evaluate this project before npm ci. It is gone: fdroidserver dropped it in fe938367, "Don't run gradle clean", on 2026-03-26. Nothing invokes Gradle before the scan now.

So there is no prebuild: any more, build: does all four steps, and the scan sees a pristine checkout, which took scandelete with the scanignore. Built in their container against fdroidserver master, the entire scan is one line, for a file fdroidserver removes itself:

INFO: Removing gradle-wrapper.jar at android/gradle/wrapper/gradle-wrapper.jar
INFO: Successfully built dev.hypercalendar.app:7 from 28671a13e0bab...
INFO: compared built binary to supplied reference binary successfully

That third line is the reproducible-build check: the APK still matches the published 2.1.1 byte for byte, so nothing about signing or publishing moved.

The recipe now depends on that upstream change, which is worth knowing before touching it. If fdroidserver ever cleans before scanning again, Gradle cannot evaluate the project and the build fails until npm ci and npx cap update android move back into prebuild:, with scandelete: node_modules behind them, because the scanner then finds esbuild's binary, @capacitor/cli's template tarballs and the npm package's own sqlite3.wasm sitting in node_modules. The released fdroidserver 2.4.5 still cleans, so fdroid build from a pipx install is not a valid test of this recipe. Step 5 below installs master, which is what their CI runs.

None of these changes needs a new tag or a new release, because the recipe lives in their repository, not this one.

Reproducible builds

The recipe carries both halves:

yaml
Binaries: https://gitlab.com/api/v4/projects/84857751/packages/generic/hyper-calendar-android/%v/hyper-calendar-%v.apk
AllowedAPKSigningKeys: 6dbe5c8ec768ff0cf409247f08717a6d42ab87aa4c9690ea626be3f28024589d

F-Droid still builds from source. It then downloads that URL, checks the APK is signed with that certificate, and compares it against its own build byte for byte outside the signing block. If they match it publishes our APK rather than re-signing with its key.

That is what collapses three channels into one signature. The GitLab release, the project's own F-Droid repository and the official F-Droid repository are the same file, so a user moves between them as an ordinary upgrade instead of uninstalling and losing their calendar.

The URL is the generic package registry build-apk uploads to, which is public on a public project. Not the Pages copy under /fdroid/repo/: every pages deployment re-creates that tree and GitLab keeps only the newest, so it is the wrong thing to pin a verification to. %v is the versionName, which scripts/check-version.mjs already forces to equal the VERSION in the upload path.

It works today, and that was checked rather than assumed. F-Droid's own build of versionCode 6 was taken from their pipeline, the published APK's signing block transplanted onto it with apksigcopier, and the result is byte-identical to the published file — 464 zip entries, same order, same CRCs:

sh
apksigcopier extract hyper-calendar-2.1.0.apk sigmeta
apksigcopier patch sigmeta dev.hypercalendar.app_6.apk patched.apk
cmp patched.apk hyper-calendar-2.1.0.apk     # identical

The APK must carry no extra signing blocks. AGP embeds a "Dependency metadata" blob in the APK signing block by default: the dependency list, encrypted with a Google public key, for Play to read. fdroidserver's scanner refuses an APK that has one:

ERROR Found extra signing block 'Dependency metadata' in dev.hypercalendar.app_6.binary.apk

This never came up while F-Droid signed its own build, because the block only exists in a signed APK and F-Droid's build output is unsigned. It appeared the moment Binaries: made F-Droid scan ours, and it is why versionCode 6 could not be the version F-Droid publishes. android/app/build.gradle now turns it off:

groovy
dependenciesInfo {
    includeInApk = false
    includeInBundle = false
}

Confirmed against the published APKs by reading their signing blocks back:

2.1.02.1.1
v2 signature2488 bytes2488 bytes
Dependency metadata3543 bytesgone
padding2105 bytes1560 bytes

The signing certificate is unchanged across the two, so AllowedAPKSigningKeys still matches.

What this costs. Two things, and both are real:

  • A release that stops reproducing does not get published on F-Droid until it is fixed. Anything that lets the build depend on the machine it ran on breaks it: an embedded timestamp, an absolute path, a locale-dependent sort, a dependency resolved at build time to something other than what the lockfile pins. The sqlite-wasm compile is the part most likely to drift, which is why the recipe pins emscripten to trixie's version rather than letting apt pick.
  • The signing key now reaches F-Droid's users. With F-Droid signing, a compromise of ANDROID_KEYSTORE_BASE64 could not produce an update F-Droid would ship, because F-Droid built from source itself. Now it could. The key lives in protected, masked CI variables and only tag pipelines can read it; that is the whole of the mitigation, and it is worth knowing rather than discovering.

AllowedAPKSigningKeys is not stripped out of the metadata generated for the project's own repository, unlike Binaries. It is just as true there, and fdroid update enforces it: an APK in fdroid/repo/ signed with anything else is refused rather than indexed. That backs up the ANDROID_CERT_SHA256 check scripts/fetch-published-apks.py makes one stage earlier — the first stops a foreign APK being staged, the second stops one being published.

Review is a volunteer queue with thousands of apps in it, so weeks of silence is normal and means nothing. Two things to watch for: a reviewer comment, and a push to the merge request's branch, which is allowed because the request has "allow commits from members who can merge" set.

Once merged, F-Droid builds and signs with their own key on their own schedule, usually about a day later.

After the first merge, releases are picked up automatically

UpdateCheckMode: Tags and AutoUpdateMode: Version mean their bot watches this repository's tags and opens the version bump itself. There is no second merge request per release. What a release still has to do is unchanged:

  1. bump versionCode in android/app/build.gradle, monotonically,
  2. write fastlane/metadata/android/en-US/changelogs/<versionCode>.txt,
  3. tag main with a signed vX.Y.Z.

scripts/check-version.mjs enforces 1 and 2 at tag time.

The trap is that the bot copies the Builds block verbatim into the new version, sudo: and build: included. So a change to how the app is built has to reach the fdroiddata copy deliberately; nothing detects the drift until one of their builds fails. That is the same warning the header of fdroid/dev.hypercalendar.app.yml carries, from the other direction.

Prerequisites:

  • A public, durable source repository. gitlab.com/kreuz-com-group/hyper-calendar is public and anonymously clonable (repository_access_level: enabled).
  • A free licence: AGPL-3.0-only.
  • No Google Play Services. The com.google.gms:google-services plugin Capacitor scaffolds in by default has been removed from android/build.gradle; nothing here uses Firebase.
  • Nothing prebuilt in the APK. This is the one that took work — see below.
  • A tag to build from, with a monotonically increasing versionCode.

SQLite is compiled from source

The standalone build runs its backend against SQLite compiled to WebAssembly. That WASM normally arrives prebuilt inside the @sqlite.org/sqlite-wasm npm package, and for F-Droid that is disqualifying twice over: their policy is that everything in an APK is compiled from source during their build, and fdroidserver's scanner rejects a .wasm on sight ("WebAssembly binary file").

Swapping SQLite out is not an option either — web/src/standalone/backend/sqlite.ts is built around a synchronous Drizzle handle on purpose, and every native or bridged SQLite is asynchronous. So the WASM is built from source instead:

sh
npm run build:sqlite-wasm     # scripts/build-sqlite-wasm.sh

That script downloads a SQLite source archive pinned by sha256, compiles ext/wasm with Debian's emscripten, and stages the three files the module reaches for into web/vendor/sqlite-wasm/. web/vite.config.ts prefers that build over the npm package whenever it exists.

Three details are worth knowing before touching any of it:

  • The whole toolchain is a Debian package. F-Droid builds on registry.gitlab.com/fdroid/fdroidserver:buildserver-trixie, and trixie ships emscripten 3.1.69, binaryen (wasm-opt) and wabt (wasm-strip) — all of which SQLite's own makefile expects. No SDK download, no emsdk bootstrap.
  • SQLite's configure wants an emsdk layout, not just a compiler.proj-check-emsdk looks for $EMSDK/emsdk_env.sh and refuses to emit ext/wasm/config.make without it. The generated tool/emcc.sh wrapper then falls back to which emcc when configure found no SDK-shaped compiler to hardcode — so the script passes --with-emsdk a directory containing a do-nothing emsdk_env.sh, and Debian's /usr/bin/emcc is found from the PATH.
  • sqlite3-bundler-friendly.mjs, not sqlite3.mjs. It is the variant SQLite builds for bundlers: it reaches its .wasm through new URL(..., import.meta.url), which Vite rewrites into a hashed asset, and it is what the npm package re-exports — so it is a drop-in for the single import in sqlite.ts. It is staged alongside sqlite3.wasm and sqlite3-opfs-async-proxy.js; the proxy is there even though this app never runs it — it belongs to the plain opfs VFS and sqlite.ts uses opfs-sahpool — because Vite resolves that new URL at build time whether or not the branch is reachable. Dropping it would change the build, not shrink it.

One consequence worth knowing before editing the recipe: everything runs in build:, and nothing should move back to prebuild:. prebuild: runs before fdroidserver's source scan, so whatever it produces is what the scanner inspects, and that is how the recipe came to carry a scanignore for the one compiled artifact that does ship. build: runs after the scan instead, so the scanner sees this repository and nothing else. Gradle is not invoked until later still, which is why npm ci and cap sync can be that late even though android/capacitor.settings.gradle points every Capacitor module at ../node_modules/@capacitor/*. The status section above has the upstream change that allows this, and what to do if it is ever reverted.

Setting HYPERCAL_SQLITE_FROM_SOURCE=1 on the vite build turns "prefer the compiled one" into "require it". Both the F-Droid recipe and CI set it, and the reason is not hypothetical: without it, dropping the compile step would not break anything — the build would quietly go back to the npm blob and the APK would stop being what the F-Droid submission says it is. Local development does not set it, so npm run dev:local still works with no emscripten installed.

The store listing lives in this repository, in the fastlane/Triple-T layout F-Droid reads from the source tree when it builds:

fastlane/metadata/android/en-US/
  title.txt  short_description.txt  full_description.txt
  changelogs/<versionCode>.txt
  images/icon.png  images/featureGraphic.png
  images/phoneScreenshots/{1,2,3}.png
  images/tenInchScreenshots/1.png

The text is hand-written and this is its only copy. The recipe carries no Summary or Description; scripts/build-fdroid-metadata.mjs combines the recipe with this text to produce the metadata our own F-Droid repository needs, because that one indexes prebuilt APKs and never clones the source. Edit the fastlane files.

short_description.txt has an 80-character limit, enforced by that script so it fails here rather than at fdroid lint inside a fork of fdroiddata. changelogs/<versionCode>.txt has a 500-character limit and is required: scripts/check-version.mjs fails a release tag whose versionCode has no changelog, because F-Droid will otherwise show a blank "What's New" and nobody notices until it is in front of users.

The images are generated — do not edit them by hand, they are overwritten:

CommandProduces
npm run iconsicon.png and featureGraphic.png, from assets/*.svg
npm run screenshotsthe screenshots, by driving the standalone build

npm run screenshots captures twelve frames in both themes into assets/screenshots/, then fans them out to the three places that need them: this fastlane tree, web/public/screenshots/ (named by the PWA manifest) and docs/public/screenshots/ (the documentation site, which shows both themes). Before that script these were hand-copied duplicates, which is how they drifted.

How the images reach the F-Droid client

Worth knowing, because getting it wrong fails silently. fdroid update does not read images out of the metadata YAML — it finds them on disk under fdroid/repo/<applicationId>/<locale>/, in this same fastlane layout. build-fdroid-metadata.mjs stages them there, and the site job publishes fdroid/repo as an artifact so the pages job still has them when it runs fdroid update.

None of that is optional: without the staging step fdroidserver happily indexes the app with no pictures at all, and the omission only becomes visible on a phone. Note this is separate from fdroid/repo/icons/icon.png, which is the repository's own icon in the client's list of repositories.

To submit (quick start guide):

  1. Install the tooling: pipx install fdroidserver.

  2. Fork https://gitlab.com/fdroid/fdroiddata, clone it, and make a branch named after the application id — dev.hypercalendar.app.

  3. Copy this repo's fdroid/dev.hypercalendar.app.yml to metadata/dev.hypercalendar.app.yml in the fork. Keep the two in step from then on — the copy here is the one that gets reviewed alongside code changes.

  4. From the fdroiddata checkout, validate:

    sh
    fdroid readmeta
    fdroid rewritemeta dev.hypercalendar.app     # canonical formatting
    fdroid lint dev.hypercalendar.app
    fdroid checkupdates --allow-dirty dev.hypercalendar.app
  5. Build it the way F-Droid will, in their own container. This is the step that catches a recipe which only works on your machine, and it is worth doing before opening the merge request rather than after a reviewer hits it. Run it from the fdroiddata checkout:

    sh
    docker run --rm -v "$PWD":/home/vagrant/fdroiddata \
      registry.gitlab.com/fdroid/fdroidserver:buildserver-trixie bash -lc '
        set -eu
        source /etc/profile.d/bsenv.sh
        # the image carries the SDK and nothing else: no fdroidserver, no JDK
        apt-get update -qq
        apt-get install -y -qq sudo openjdk-21-jdk-headless curl > /dev/null
        update-alternatives --set java /usr/lib/jvm/java-21-openjdk-amd64/bin/java
        sdkmanager "platform-tools" "build-tools;31.0.0" > /dev/null
        rm -rf "$fdroidserver" && mkdir -p "$fdroidserver"
        curl -s https://gitlab.com/fdroid/fdroidserver/-/archive/master/fdroidserver-master.tar.gz \
          | tar -xz --directory="$fdroidserver" --strip-components=1
        export PATH="$fdroidserver:$PATH" PYTHONPATH="$fdroidserver:$fdroidserver/examples"
        export GRADLE_USER_HOME="$home_vagrant/.gradle" serverwebroot=/tmp
        export gpghome=/tmp/gnupghome keystore="$fdroidserver/tests/keystore.jks"
        export keystorepass=x keypass=x
        cd "$home_vagrant/fdroiddata"
        mkdir -p build tmp logs "$GRADLE_USER_HOME" "$gpghome" && chmod 0700 "$gpghome"
        chmod 0600 config.yml
        chown -R vagrant build tmp logs "$GRADLE_USER_HOME" "$gpghome"
        exec sudo --preserve-env --user vagrant env PATH="$PATH" \
          PYTHONPATH="$PYTHONPATH" HOME="$home_vagrant" ANDROID_HOME="$ANDROID_HOME" \
          GRADLE_USER_HOME="$GRADLE_USER_HOME" serverwebroot=/tmp gpghome="$gpghome" \
          keystore="$keystore" keystorepass=x keypass=x \
          fdroid build --verbose --test --refresh-scanner --on-server --no-tarball \
            dev.hypercalendar.app:7'

    The APK lands in tmp/. Three details are worth knowing, because a shorter command looks like it works and does not:

    • --on-server is not optional. Without it fdroidserver skips the sudo: block entirely and only logs a warning, so the build runs against whatever the image happens to ship. That is how a broken sudo: block can sit unnoticed. -l is --latest, not "local".
    • It runs as vagrant, not root, the way their CI does. --on-server shells out to sudo for the sudo: block, and the image has a passwordless rule for that user.
    • fdroidserver comes from git master, which is what their CI installs. The released version packaged for your machine is usually older.
  6. Open a merge request against fdroiddata, branch dev.hypercalendar.app, commit message New App: dev.hypercalendar.app. Three review comments are worth pre-empting in the description:

    • npm ci fetches dependencies. All free software, pinned by package-lock.json, and none of it reaches the APK as a binary.
    • There is a .wasm in the APK. It is compiled by this build, from a SQLite source archive pinned by sha256, with Debian's emscripten — and the build fails rather than falling back to the npm package's prebuilt copy. See SQLite is compiled from source.
    • No scanignore and no scandelete. Everything runs in build:, which is after the scan, so the scanner sees a pristine checkout: no node_modules, no bundle, no compiled SQLite.
  7. Once merged, the app appears in the main repository within roughly 24-48 hours. UpdateCheckMode: Tags and AutoUpdateMode: Version mean later releases are picked up from new tags without another merge request — but note that AutoUpdateMode copies the whole Builds entry forward verbatim, sudo: and build: included. Renaming build:sqlite-wasm, or bumping the pinned SQLite or Node version, means updating the fdroiddata copy too.

Reproducible builds are not required, though F-Droid considers them best practice. Worth deciding early rather than later: moving to reproducible builds after the fact is awkward, because Android requires the same signing key across updates.

Release checklist for either channel:

  1. Write fastlane/metadata/android/en-US/changelogs/<versionCode>.txt (500 characters at most). scripts/check-version.mjs fails the tag without it.
  2. Bump versionCode (must increase monotonically) and versionName in android/app/build.gradle, and match versionName to package.json.
  3. Tag the release; the metadata uses UpdateCheckMode: Tags.
  4. Update versionName/versionCode/commit in fdroid/dev.hypercalendar.app.yml — and in the fdroiddata copy, if the app has been accepted there.

Known constraint: WebView version

OPFS sync access handles need Android System WebView 108 or newer. On an older one the app falls back to an in-memory database and the first-run screen says so plainly instead of silently losing data. minSdk is 24, but the WebView is updated independently of the OS, so the SDK level is not a reliable proxy for this.