Merge branch 'main' into release/v2.17.0

ARCHITECTURE.md: keep main's corrected ui/ description and the new floret-kit
section, plus the settings sub-screen note from this branch.
This commit is contained in:
2026-07-30 22:04:53 +02:00
24 changed files with 839 additions and 900 deletions

View File

@@ -20,7 +20,7 @@ the package list (recurring writes, save conflicts, reminder delivery).
repository tests. Instrumented tests are a last resort.
4. **No network.** The app declares no `INTERNET` permission. Anything that
would need one is an explicit, documented product decision first
(see the roadmap's idea backlog).
(the crash reporter's web-issue path is the worked example).
## Layers
@@ -62,11 +62,18 @@ flowchart TD
- **`data/prefs/`** — DataStore-backed settings (theme, week start, form
field defaults, reminders toggle) and small state (last-used calendar).
- **`ui/`** — one package per screen, each with Screen + ViewModel +
UiState. Shared pieces in `ui/common/` (OptionCard — the app's only
sanctioned selection-dialog style —, recurrence humanizer, FAB column,
drawer, transitions). `ui/settings/` is the exception to "one file per
screen": one `SettingsViewModel` feeds a hub (`SettingsScreen.kt`) plus a
sub-screen per category, each in its own `*Settings.kt`.
UiState. Shared pieces in `ui/common/` (recurrence humanizer, FAB column,
drawer, transitions). Selection pickers are full-screen and come from
floret-kit (`FullScreenPicker` / `OptionPicker`); `AlertDialog` is reserved
for plain confirmations and the compact recurring-scope choosers.
`ui/settings/` is the exception to "one file per screen": one
`SettingsViewModel` feeds a hub (`SettingsScreen.kt`) plus a sub-screen per
category, each in its own `*Settings.kt`.
- **`floret-kit/`** — the shared Material 3 Expressive kit for the Floret app
family, wired in as a git submodule *and* a Gradle composite build
(`includeBuild`), so it is compiled from source rather than resolved as a
dependency. Pickers, crash plumbing, and locale/time helpers live there;
changing them is a pull request against that repository plus a submodule bump.
## Navigation
@@ -83,7 +90,7 @@ exactly like an event tap.
## Recurring writes
The provider's invariants drive the design (learned the hard way, verified
on-device — see plan 03):
on-device):
- Recurring rows carry `RRULE` + `DURATION` (no `DTEND`); one-off rows
carry `DTEND`.
@@ -226,6 +233,7 @@ JUnit 5 + Truth + Turbine on the JVM. The seams that make it work:
`CalendarDataSource` is faked (`FakeCalendarDataSource` records writes),
mappers parse `ColumnReader`/plain maps instead of cursors, domain logic
(recurrence, validation, snapshots, write-value building) is pure. CI
(Gitea Actions) runs `lint test assembleDebug` once per pull request; merging a
(Forgejo Actions on Codeberg) runs `lint test assembleDebug` once per pull
request; merging a
bumped `versionName` to `main` builds, signs, and publishes to the self-hosted
F-Droid repo and then mints the `vX.Y.Z` tag + release. See docs/RELEASING.md.

View File

@@ -1,12 +1,35 @@
# Building from source
Calendula builds with the standard Android Gradle toolchain — no extra setup
beyond the SDK and a JDK.
beyond the SDK, a JDK, and the submodule.
## Clone
```bash
git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git
```
Calendula depends on **[floret-kit](https://codeberg.org/jlmakiola/floret-kit)**,
the shared Material 3 Expressive kit, as a git submodule wired in as a Gradle
composite build (`includeBuild("floret-kit")` in `settings.gradle.kts`) — it is
compiled from source, not resolved from a repository. A clone without the
submodule fails to configure. For an existing clone:
```bash
git submodule update --init --recursive
```
## Requirements
- **Android SDK 36+**
- **JDK 17** — if your default JDK is not 17, set `JAVA_HOME` explicitly.
- **JDK 17** — not newer; the Android Gradle Plugin requires exactly 17. If your
default JDK differs, set `JAVA_HOME` explicitly.
- **Android SDK** — platform **37** (`compileSdk`) and **build-tools 36.0.0**.
`minSdk` is 29, `targetSdk` 36.
The SDK is located via `ANDROID_HOME` (or `ANDROID_SDK_ROOT`), or via a
gitignored `local.properties` with `sdk.dir`. If you use `local.properties`, note
that the composite build needs **its own** copy at `floret-kit/local.properties`;
setting `ANDROID_HOME` covers both builds at once and is the simpler route.
The Gradle wrapper is checked in, so you don't need a system Gradle.
@@ -14,11 +37,44 @@ The Gradle wrapper is checked in, so you don't need a system Gradle.
```bash
./gradlew assembleDebug # debug APK → app/build/outputs/apk/debug/
./gradlew test # JVM unit tests
./gradlew test # JVM unit tests (JUnit 5 + Truth + Turbine)
./gradlew lint # Android lint
```
A single test class, or a pattern:
```bash
./gradlew testDebugUnitTest --tests "de.jeanlucmakiola.calendula.domain.SimpleRecurrenceTest"
./gradlew testDebugUnitTest --tests "*SimpleRecurrence*"
```
Translation-key invariants (stale and orphaned keys are fatal; missing ones are
not) are checked outside Gradle:
```bash
python3 scripts/check_translations.py
```
## What CI runs
`.forgejo/workflows/ci.yaml` reports a single `CI` check per pull request on
Codeberg: the reproducible-release guard, then `lintDebug`,
`testDebugUnitTest`, `assembleDebug` and a Trivy filesystem scan. Pull requests
that touch only docs, F-Droid metadata or the licence skip the Android build and
still report green.
## Release builds
Release signing and the publishing pipeline are documented separately in
[RELEASING.md](RELEASING.md).
[RELEASING.md](RELEASING.md). To smoke-test a release candidate locally, the
`releaseTest` build type is an R8-shrunk twin of `release`, debug-signed with its
own `applicationId` suffix so it installs alongside the real app:
```bash
scripts/verify-release.sh
```
## Contributing
See [`../CONTRIBUTING.md`](../CONTRIBUTING.md) for the issue-first workflow,
which branch to target, and the architectural rules a change is reviewed against.

View File

@@ -4,19 +4,22 @@ Where to look for what:
| Document | What it is |
|---|---|
| [`../CONTRIBUTING.md`](../CONTRIBUTING.md) | How to contribute: issue-first workflow, which branch to target, translations, the rules a change is reviewed against |
| [`BUILDING.md`](BUILDING.md) | Building from source: submodule, JDK/SDK requirements, Gradle tasks, what CI runs |
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | Orientation tour: principles, layers, navigation, recurring-write / conflict / reminder pipelines, testing |
| [`RELEASING.md`](RELEASING.md) | Release process: versioning, the merge-driven pipeline, the two-forge split, secrets, key custody |
| [`../CHANGELOG.md`](../CHANGELOG.md) | Release history (Keep a Changelog, SemVer) |
| [`../.planning/ROADMAP.md`](../.planning/ROADMAP.md) | Living roadmap: shipped milestones, current scope, idea backlog |
| [`../.planning/PROJECT.md`](../.planning/PROJECT.md) | What the project is, stack, naming, infrastructure |
| [`../.planning/REQUIREMENTS.md`](../.planning/REQUIREMENTS.md) | Requirement checklist per milestone |
| [`../.planning/STATE.md`](../.planning/STATE.md) | Snapshot of where development currently stands |
| [`superpowers/specs/`](superpowers/specs/) | The original design spec (2026-06-08) — historical record, not updated |
| [`superpowers/plans/`](superpowers/plans/) | Per-milestone implementation plans with task checklists — historical record of how each slice was built, including provider lessons learned |
| [Issues](https://codeberg.org/jlmakiola/calendula/issues) + [milestones](https://codeberg.org/jlmakiola/calendula/milestones) | **The roadmap.** What's planned, in progress, and shipped — a milestone maps to its `release/vX.Y.Z` branch |
| [`../.planning/PROJECT.md`](../.planning/PROJECT.md) | What the project is: core value, stack + version pins, constraints, naming, forge/release infrastructure |
| [`design/`](design/) | Per-feature design notes kept for features whose provider behaviour is worth recording |
| [`../fastlane/metadata/android/`](../fastlane/metadata/android/) | Store metadata (single source of truth): descriptions, title, icon, screenshots (DE + EN). Harvested directly by the official F-Droid repo; transformed into the self-hosted repo layout at release time by [`../scripts/fastlane_to_fdroid_localized.sh`](../scripts/fastlane_to_fdroid_localized.sh) |
| [`../fdroid-metadata/`](../fdroid-metadata/) | App-level F-Droid control file (`*.yml`: Categories, License, links) for the self-hosted repo's `fdroid update` |
| [`fdroid-official/`](fdroid-official/) | Draft recipe + notes for publishing to the **official** F-Droid repo (reproducible build + developer-signed binary) |
| [`fdroid-official/`](fdroid-official/) | Recipe + notes for publishing to the **official** F-Droid repo (reproducible build + developer-signed binary) |
Conventions: plans and specs under `superpowers/` are point-in-time
artifacts of the agentic workflow that built each milestone — they get
status updates but are never rewritten. The `.planning/` files are living
documents and should stay current.
Conventions: planning lives in the **issue tracker**, not in this repository. The
`.planning/` files that predated it (a roadmap, a development-state snapshot, and
a per-milestone requirement checklist) are gone — issues and milestones say the
same thing without going stale. `PROJECT.md` is what remains, and it describes
the project rather than its plan.
`ARCHITECTURE.md` is the authoritative orientation tour: it is updated with the
code, and is the right place for a lesson learned about the calendar provider.

View File

@@ -87,6 +87,9 @@ release work when a merge actually cuts a release:
mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum
(both best-effort). Ordinary merges with no version bump fall through `detect`
and do nothing.
- **`play` job** (same workflow, after `release`) — uploads the App Bundle to
Google Play. Runs last and separately so a Play rejection can't endanger a
release that already shipped; skips cleanly until Play is configured.
### Codeberg direct-download channel
@@ -102,6 +105,58 @@ unset. One-time setup: the repo's **Releases** unit must be enabled and a
`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope) added
to Gitea Actions.
### Google Play channel
Play is a third channel alongside F-Droid and the Codeberg download, and it is
the only one that gets a different artifact and a different signature.
**Artifact.** Play takes an **App Bundle** (`bundleRelease`), not the APK. It is
a second output of the same source and the same signing config — never a
repackage of the published APK, which stays untouched so the F-Droid
reproducibility guarantee is unaffected. `dependenciesInfo` stays disabled for
the bundle too; Play's "app dependencies" report is optional and re-enabling it
would break reproducibility.
**Signature — read this before assuming an update path exists.** Play App
Signing is mandatory for new apps, and Google generates and holds the app
signing key. The release keystore in CI is registered only as the **upload
key**: Play verifies uploads with it, then re-signs with Google's key before
delivery. Consequences, accepted deliberately:
- A Play install and an F-Droid install have **different signatures** and
**cannot update each other**. Switching channels requires uninstall +
reinstall, which loses nothing (all data lives in the system calendar
provider) but must be stated wherever both channels are advertised.
- Losing the upload key is **recoverable** — request an upload-key reset in the
Play Console. Losing the app key still is not, for F-Droid.
**Build and signing are not fastlane's job.** Gradle does both, exactly as
before. fastlane appears only as the Play Developer API client (`supply`),
because the store listing already lives in `fastlane/metadata/android/` — the
same tree the official F-Droid repo harvests. One metadata source, two stores.
**What gets uploaded per release:** the AAB, plus the per-version "What's New"
from `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` (generated
from `CHANGELOG.md` by `scripts/sync_changelog_to_fastlane.sh`). Listing text is
**not** touched — an accidental overwrite of a live listing triggers a Play
policy review. Sync it deliberately with `bundle exec fastlane listing`.
**Screenshots and graphics are skipped**, because the committed assets satisfy
F-Droid but not Play:
| Asset | Committed | Play requires |
| --- | --- | --- |
| `phoneScreenshots/*.png` | 1280×2856, 32-bit RGBA | long edge ≤ 2× short edge (so ≤ 2560), 24-bit PNG, no alpha |
| `icon.png` | 512×512, 24-bit RGB | 512×512, 32-bit PNG |
| `featureGraphic.png` | *missing* | required, exactly 1024×500 |
Until those are fixed, Play's graphics are managed by hand in the console. Then
pass `images:true` to the `listing` lane.
**Track.** Uploads go to `internal` by default; promotion to production stays a
manual action in the Play Console, matching the rule that UI releases wait for
on-device review. Override with the `PLAY_TRACK` repo variable.
### Manual re-sign / recovery
A manual `workflow_dispatch` of the release workflow runs a **re-sign-only**
@@ -153,6 +208,15 @@ Two consequences worth remembering:
| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | Upload target for the F-Droid repo. |
| `GITHUB_TOKEN` | Provided by Gitea Actions; used to create the release + attach assets. |
| `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope) — creates the mirrored Codeberg release + uploads the APK/checksum. Best-effort; if unset the Codeberg step skips. |
| `PLAY_SERVICE_ACCOUNT_JSON` | Google Cloud service-account key (full JSON) with Play Console access — uploads the AAB. If unset, the `play` job skips cleanly. |
### Variables (Gitea → repo Settings → Actions → Variables)
| Variable | Default | Purpose |
| --- | --- | --- |
| `PLAY_TRACK` | `internal` | Play track the bundle is uploaded to. |
| `PLAY_RELEASE_STATUS` | `completed` | `completed`, `draft`, `inProgress` or `halted`. |
| `PLAY_DRY_RUN` | `false` | `true` validates the Play edit against the API and discards it — use to rehearse. |
The two keys are independent: the **app key** signs APKs; the **repo key**
signs the index (its fingerprint is what users pin). Neither key nor the