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 makesSet-Cookiea forbidden response-header name andCookiea forbidden request-header name, so a browserResponse/Requestcannot carry either. Cookies travel over private header names instead, with the jar instandalone/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
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 emscriptenOnly 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:
cd android && ./gradlew assembleReleaseReleases: 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:
| Variable | What it holds |
|---|---|
ANDROID_KEYSTORE_BASE64 | the keystore itself, base64, masked |
ANDROID_KEYSTORE_PASSWORD | storePassword, masked |
ANDROID_KEY_ALIAS | keyAlias |
ANDROID_KEY_PASSWORD | keyPassword, masked |
ANDROID_CERT_SHA256 | signing 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:
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 serverThe 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_CONTRIBUTORand 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
| Area | Standalone behaviour |
|---|---|
| Reminders | Scheduled as Android notifications ~14 days ahead and refreshed on resume, instead of Web Push. Same due-time logic (core/src/lib/reminderScheduler.ts). |
| Calendar feeds | Fetched 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 sync | Works 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 backups | The 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, invitations | Work between multiple accounts registered on the same device; across devices they need server mode. |
| Voice control | Not included — see below. |
| Device calendars | Android only, and only in standalone mode — see above. The hosted app has no calendar provider to read. |
| Android backup | Disabled 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:6A 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 reads1c0da59d847a79aa28c3e6375af9337b3767a4e1, which is exactly whatv2.1.0points at, so nothing about what gets built changed. This also costs nothing later:fdroid checkupdatesresolves the tag it finds throughvcs.getrefbefore 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 npmwith-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
emscriptenfrom 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 forkydoes. Verified in adebian:trixiecontainer: with the pin,node -vis 24.19.0 andemcc --versionis 3.1.69; without it,emccis 6.0.5.Node is installed before the rest of the toolchain, because
emscriptenonly requiresnodejs (>= 12). Having node there already satisfies it, so trixie's node 20 is never installed beside it.libc6-devis installed from forky alongside node, added on 2026-09-16.-t forkymakes apt take the whole dependency closure from forky,libc6included. Trixie'slibc6-devholds itself to the olderlibc6, so it stops being installable and takesbuild-essentialwith 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, sincelibc6was 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
aptline 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-utilswent with the tarball;curlandunzipstay, sincescripts/build-sqlite-wasm.shneeds 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 6dbe5c8ec768ff0cf409247f08717a6d42ab87aa4c9690ea626be3f28024589dlinsui 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 successfullyThat 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:
Binaries: https://gitlab.com/api/v4/projects/84857751/packages/generic/hyper-calendar-android/%v/hyper-calendar-%v.apk
AllowedAPKSigningKeys: 6dbe5c8ec768ff0cf409247f08717a6d42ab87aa4c9690ea626be3f28024589dF-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:
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 # identicalThe 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.apkThis 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:
dependenciesInfo {
includeInApk = false
includeInBundle = false
}Confirmed against the published APKs by reading their signing blocks back:
| 2.1.0 | 2.1.1 | |
|---|---|---|
| v2 signature | 2488 bytes | 2488 bytes |
| Dependency metadata | 3543 bytes | gone |
| padding | 2105 bytes | 1560 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-wasmcompile 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_BASE64could 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:
- bump
versionCodeinandroid/app/build.gradle, monotonically, - write
fastlane/metadata/android/en-US/changelogs/<versionCode>.txt, - tag
mainwith a signedvX.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-calendaris public and anonymously clonable (repository_access_level: enabled). - A free licence: AGPL-3.0-only.
- No Google Play Services. The
com.google.gms:google-servicesplugin Capacitor scaffolds in by default has been removed fromandroid/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:
npm run build:sqlite-wasm # scripts/build-sqlite-wasm.shThat 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 shipsemscripten3.1.69,binaryen(wasm-opt) andwabt(wasm-strip) — all of which SQLite's own makefile expects. No SDK download, noemsdkbootstrap. - SQLite's configure wants an emsdk layout, not just a compiler.
proj-check-emsdklooks for$EMSDK/emsdk_env.shand refuses to emitext/wasm/config.makewithout it. The generatedtool/emcc.shwrapper then falls back towhich emccwhen configure found no SDK-shaped compiler to hardcode — so the script passes--with-emsdka directory containing a do-nothingemsdk_env.sh, and Debian's/usr/bin/emccis found from the PATH. sqlite3-bundler-friendly.mjs, notsqlite3.mjs. It is the variant SQLite builds for bundlers: it reaches its.wasmthroughnew 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 insqlite.ts. It is staged alongsidesqlite3.wasmandsqlite3-opfs-async-proxy.js; the proxy is there even though this app never runs it — it belongs to the plainopfsVFS andsqlite.tsusesopfs-sahpool— because Vite resolves thatnew URLat 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.pngThe 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:
| Command | Produces |
|---|---|
npm run icons | icon.png and featureGraphic.png, from assets/*.svg |
npm run screenshots | the 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):
Install the tooling:
pipx install fdroidserver.Fork https://gitlab.com/fdroid/fdroiddata, clone it, and make a branch named after the application id —
dev.hypercalendar.app.Copy this repo's
fdroid/dev.hypercalendar.app.ymltometadata/dev.hypercalendar.app.ymlin the fork. Keep the two in step from then on — the copy here is the one that gets reviewed alongside code changes.From the fdroiddata checkout, validate:
shfdroid readmeta fdroid rewritemeta dev.hypercalendar.app # canonical formatting fdroid lint dev.hypercalendar.app fdroid checkupdates --allow-dirty dev.hypercalendar.appBuild 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:
shdocker 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-serveris not optional. Without it fdroidserver skips thesudo:block entirely and only logs a warning, so the build runs against whatever the image happens to ship. That is how a brokensudo:block can sit unnoticed.-lis--latest, not "local".- It runs as
vagrant, not root, the way their CI does.--on-servershells out tosudofor thesudo: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.
Open a merge request against
fdroiddata, branchdev.hypercalendar.app, commit messageNew App: dev.hypercalendar.app. Three review comments are worth pre-empting in the description:npm cifetches dependencies. All free software, pinned bypackage-lock.json, and none of it reaches the APK as a binary.- There is a
.wasmin 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
scanignoreand noscandelete. Everything runs inbuild:, which is after the scan, so the scanner sees a pristine checkout: nonode_modules, no bundle, no compiled SQLite.
Once merged, the app appears in the main repository within roughly 24-48 hours.
UpdateCheckMode: TagsandAutoUpdateMode: Versionmean later releases are picked up from new tags without another merge request — but note that AutoUpdateMode copies the wholeBuildsentry forward verbatim,sudo:andbuild:included. Renamingbuild: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:
- Write
fastlane/metadata/android/en-US/changelogs/<versionCode>.txt(500 characters at most).scripts/check-version.mjsfails the tag without it. - Bump
versionCode(must increase monotonically) andversionNameinandroid/app/build.gradle, and matchversionNametopackage.json. - Tag the release; the metadata uses
UpdateCheckMode: Tags. - Update
versionName/versionCode/commitinfdroid/dev.hypercalendar.app.yml— and in thefdroiddatacopy, 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.