The Android app
hyper-calendar 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 hyper-calendar 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. Every route test in core/src/**/*.test.ts therefore covers the app too.
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 is what keeps week-view gestures smooth.
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 checksRun 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 hyper-calendar-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 hyper-calendar 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.
Four 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 |
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 key. F-Droid signs its own build, so the APK from a GitLab release and the one from F-Droid have different signatures and Android will not upgrade between them — a user has to uninstall to switch channels. That is normal for dual distribution, but say so wherever both are offered.
Device calendars
The app can mirror the calendars already on the phone — Google, Exchange, or a purely local account — through Android's CalendarContract, both directions. They appear under On this device in the sidebar, grouped by account, and their events behave like any other: week 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. Mirrored 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 hyper-calendar 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.
- Read-only mirrors. Below
CAL_ACCESS_CONTRIBUTORthe provider refuses writes (a subscribed holiday calendar, typically), so the calendar is mirrored 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 hyper-calendar receives HTML where it expects JSON — there is no way for the app to complete a portal login. hyper-calendar 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 hyper-calendar 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 (not yet 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 and signs with its own key, so an install from there and an install from channel 1 have different signatures and Android will not upgrade between them.
Prerequisites — all now met:
- 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, and no prebuilt binaries downloaded during the build — excluding voice control is part of what makes that true. The build runs
npm ci, which F-Droid reviews; every dependency is free software and pinned bypackage-lock.json. - A tag to build from, with a monotonically increasing
versionCode.
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
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.
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 build container:
fdroid build dev.hypercalendar.app. 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.Open a merge request against
fdroiddata. Expect review comments about thenpm cidependency fetch; the answer is the paragraph above.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.
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:
- 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.