From a3d136f55d958391d45f5df82a9aa00cac3855d4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 30 Jul 2026 10:40:07 +0200 Subject: [PATCH 1/8] Write a contributing guide, and fix what the forge move left stale (#96) (docs) Update all the docs to be up to date with current status quo --- .forgejo/ISSUE_TEMPLATE/bug_report.md | 30 + .forgejo/ISSUE_TEMPLATE/config.yml | 18 + .../ISSUE_TEMPLATE/crash_report.md | 2 +- .../ISSUE_TEMPLATE/feature_request.md | 0 .../ISSUE_TEMPLATE/question.md | 0 .forgejo/PULL_REQUEST_TEMPLATE.md | 41 ++ .gitea/ISSUE_TEMPLATE/bug_report.md | 23 - .gitignore | 5 + .planning/PROJECT.md | 37 +- .planning/REQUIREMENTS.md | 53 -- .planning/ROADMAP.md | 587 ------------------ .planning/STATE.md | 177 ------ CONTRIBUTING.md | 184 ++++++ README.md | 18 +- docs/ARCHITECTURE.md | 19 +- docs/BUILDING.md | 66 +- docs/README.md | 25 +- 17 files changed, 413 insertions(+), 872 deletions(-) create mode 100644 .forgejo/ISSUE_TEMPLATE/bug_report.md create mode 100644 .forgejo/ISSUE_TEMPLATE/config.yml rename {.gitea => .forgejo}/ISSUE_TEMPLATE/crash_report.md (97%) rename {.gitea => .forgejo}/ISSUE_TEMPLATE/feature_request.md (100%) rename {.gitea => .forgejo}/ISSUE_TEMPLATE/question.md (100%) create mode 100644 .forgejo/PULL_REQUEST_TEMPLATE.md delete mode 100644 .gitea/ISSUE_TEMPLATE/bug_report.md delete mode 100644 .planning/REQUIREMENTS.md delete mode 100644 .planning/ROADMAP.md delete mode 100644 .planning/STATE.md create mode 100644 CONTRIBUTING.md diff --git a/.forgejo/ISSUE_TEMPLATE/bug_report.md b/.forgejo/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..6a4e08a --- /dev/null +++ b/.forgejo/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Something doesn't work the way it should +title: "" +labels: + - bug +--- + +### What happened + + +### What you expected + + +### Steps to reproduce +1. +2. +3. + +### Environment +- Calendula version: +- Android version: +- Device: +- Installed from: +- Affected calendar: +- Time zone: + diff --git a/.forgejo/ISSUE_TEMPLATE/config.yml b/.forgejo/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..2fc3df8 --- /dev/null +++ b/.forgejo/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,18 @@ +# Kept enabled so anything that doesn't fit the four templates still has a way +# in (the `ToDo` label exists for exactly those). +blank_issues_enabled: true + +contact_links: + - name: Translate Calendula + url: https://weblate.dev.jeanlucmakiola.de/engage/calendula/ + about: >- + Translations are managed on Weblate, not here — it owns every values-* + file, so a hand-edited translation gets overwritten on the next sync. + No coding needed: pick or request a language and translate in the browser. + + - name: Contributing guide + url: https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md + about: >- + Before opening a pull request: the issue-first workflow, which release + branch to target, how to build (there's a submodule), and the + architectural rules a change is reviewed against. diff --git a/.gitea/ISSUE_TEMPLATE/crash_report.md b/.forgejo/ISSUE_TEMPLATE/crash_report.md similarity index 97% rename from .gitea/ISSUE_TEMPLATE/crash_report.md rename to .forgejo/ISSUE_TEMPLATE/crash_report.md index 6828788..4626077 100644 --- a/.gitea/ISSUE_TEMPLATE/crash_report.md +++ b/.forgejo/ISSUE_TEMPLATE/crash_report.md @@ -5,7 +5,7 @@ title: "Crash: " labels: - bug - crash - - priority:high + - priority/high --- + +### What this changes + + +### Why + + + + +### How it was tested + + + + +### Checklist + +- [ ] There's an issue for this, and (for a feature) it got a go-ahead +- [ ] Targeting the release branch for that issue's milestone — or `main`, noted above +- [ ] `./gradlew lint test assembleDebug` passes locally +- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine) +- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible +- [ ] No planning or design documents committed diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 2500323..0000000 --- a/.gitea/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: Bug report -about: Something doesn't work the way it should -title: "" -labels: - - bug ---- - -### What happened - - -### What you expected - - -### Steps to reproduce -1. -2. -3. - -### Environment -- Calendula version: -- Android version: -- Device: diff --git a/.gitignore b/.gitignore index b170de3..3f3546f 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,11 @@ google-services.json .DS_Store Thumbs.db +# Editor swap/backup files +*.swp +*.swo +*~ + # F-Droid local artifacts (the pipeline generates them in CI) /fdroid/ diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 04ce9d3..c9f5e5a 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -17,9 +17,10 @@ re-inventing the calendar sync stack — leave that to DAVx5 and the system. ## Current Milestone Milestones 1 (read, v1.0) and 2 (write support, v1.1–v2.0.0 incl. reminder -delivery) are **complete** — v2.0.0 shipped 2026-06-11. Next is v3.0 -(power-user features) plus an undecided "Locations & People" idea backlog; -see `ROADMAP.md`. +delivery) are **complete** — v2.0.0 shipped 2026-06-11. Everything since is +tracked as issues and milestones on Codeberg: +. A milestone maps to its +`release/vX.Y.Z` branch. ## Stack @@ -27,10 +28,26 @@ Kotlin 2.3.21 (paired with KSP 2.3.9 — Kotlin 2.4.0 has no KSP release yet, do not upgrade until one ships). Jetpack Compose + Material 3 Expressive 1.5.0-alpha21 (alpha is intentional — Expressive APIs only live in the 1.5 alpha line). Hilt 2.59.2, DataStore. Gradle Kotlin DSL -with Version Catalog. AGP 9.1.1, Gradle 9.5.1. JVM target 17. +with Version Catalog. AGP 9.2.1, Gradle 9.5.1. JVM target 17 (exactly — AGP +requires it). -Android-only (minSdk 29, targetSdk 36). No iOS. No `INTERNET` permission — -any feature that would need one is an explicit product decision first. +The shared Material 3 Expressive kit, **floret-kit**, is a git submodule wired +in as a Gradle composite build, so it compiles from source rather than resolving +as a dependency. + +## Constraints + +- **Platform:** Android-only, Android 10+ (minSdk 29), targetSdk 36. No iOS. +- **Offline-first:** all data lives in `CalendarContract` — no app database, no + sync stack. No `INTERNET` permission; any feature needing one is an explicit + product decision first. +- **Privacy:** zero telemetry, zero analytics. +- **i18n:** German + English from day one; further languages come from community + translators via the self-hosted Weblate, which owns every `values-*` file. +- **Tests + CI from day one**, JVM-first. +- **Reproducible release builds**, so the official F-Droid repo can verify the + published binary against a from-source rebuild. +- **Licence:** MIT. ## Naming @@ -40,5 +57,9 @@ shows a stylized "1" on a slate squircle. ## Source -Hosted on self-hosted Gitea, released through self-hosted F-Droid repo on -Hetzner. Same infrastructure as `HouseHoldKeaper`. +**Codeberg (`jlmakiola/calendula`) is canonical** — git, issues, PRs, tags and +releases, plus contributor CI. The self-hosted Gitea instance is build +infrastructure only: it holds the signing key, runs the release pipeline, and +publishes the self-hosted F-Droid repo on Hetzner. Codeberg push-mirrors `main` +and tags to Gitea, where a bumped `versionName` triggers the release. Also +published to the official F-Droid repo. See `docs/RELEASING.md`. diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md deleted file mode 100644 index a6b970b..0000000 --- a/.planning/REQUIREMENTS.md +++ /dev/null @@ -1,53 +0,0 @@ -# Calendula — Requirements - -See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md` - -## V1 Scope (Variant "B") — shipped in full (v1.0.0, 2026-06-11) - -- [x] Foundation & CI infrastructure — v0.1.0 (2026-06-08) -- [x] Data Layer over `CalendarContract` -- [x] Permission flow (`READ_CALENDAR`) -- [x] Month view (S1) -- [x] Week view (S2) -- [x] Day view (S3) -- [x] Event Detail Sheet (S4) — became a full screen, plus full event read (v0.6) -- [x] Multi-Calendar Filter (M3) -- [x] Today button (M2) — shipped v0.5; Jump-to-Date **cut from scope** -- [x] View-Switcher (M1) -- [x] Settings screen (M4) -- [x] Empty / no-permission / no-calendars states -- [x] German + English localization -- [x] Loading/Failure/Success states per screen (architectural pattern) - -## V2 Scope — write support, shipped in full (v2.0.0, 2026-06-11) - -- [x] Write foundation: `WRITE_CALENDAR`, read-only-calendar detection, delete (v1.1) -- [x] Create event: form, FAB, last-used calendar (v1.2; polish v1.2.1) -- [x] Edit event: shared form, scoped recurring writes, recurrence picker (v1.3) -- [x] Reminder notifications (v1.4) — **reversal of the original - "system handles reminders" assumption:** Calendula targets - sole-calendar-app users, so it posts reminder notifications itself - (Etar model), incl. `POST_NOTIFICATIONS` onboarding -- [x] Conflict dialog on save + store polish (v2.0) -- Quick-add — **cut from scope** (the prefilled form covers it) -- Calendar switching while editing — moved to v3 backlog - -### Out of Scope (V3+) - -- Home-screen widget -- Full-text search -- Tablet/foldable-specific layouts -- Locations & People ideas (contact picker, OSM autocomplete) — see - `ROADMAP.md` idea backlog, undecided -- iOS support (Android-only by design) - -## Constraints - -- **Tech stack:** Kotlin + Jetpack Compose + Material 3 Expressive, Hilt, DataStore -- **Tech stack pin:** Hilt 2.59.2 + KSP 2.3.9; Kotlin 2.3.21 (KSP for Kotlin 2.4.0 not released yet). Material 3 pinned to `1.5.0-alpha21` (Expressive APIs only exist in alpha). Re-evaluate when KSP/Material3 stable land. -- **Platform:** Android 10+ (API 29 minimum), Android 16 (API 36) target -- **Offline-first:** all data lives in `CalendarContract`; no app-side network -- **Privacy:** zero telemetry, no analytics -- **i18n:** German + English from day one -- **Tests + CI from day one** -- **License:** MIT diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md deleted file mode 100644 index a27a7dc..0000000 --- a/.planning/ROADMAP.md +++ /dev/null @@ -1,587 +0,0 @@ -# Calendula — Roadmap - -## v0.x — Pre-Release - -| Version | Milestone | Status | -|---|---|---| -| v0.1 | Foundation & CI | complete | -| v0.2 | Data Layer & Permission Flow | complete | -| v0.3 | Month + Week + Day views, view switcher | complete | -| v0.4 | Event Detail (S4) + humanized recurrence | complete | -| v0.5 | Calendar filter (M3) + Settings (M4) | complete | -| v0.6 | Full event read — surface every readable field | complete | -| v1.0 | First public release — polish pass, F-Droid | complete | - -Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and -Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5. - -Jump-to-date (the date-picker half of M2) was **cut from scope** and will not -ship. The "Today" half of M2 already shipped in v0.5 (drawer entry). - -## v0.6 — Full event read - -Round out the read-only model so a detail view shows everything the system -actually stores, before write support starts. Scope = `CalendarContract` -columns we don't yet read/display: - -- **Reminders** (`VALARM`) — read `CalendarContract.Reminders`, list lead times -- **Status** — Confirmed / Tentative / Cancelled (cancelled shown struck-through) -- **Availability** (`TRANSP`) — Free / Busy chip -- **Attendee extras** — role (required / optional / organizer) + the user's own - `SELF_ATTENDEE_STATUS` -- **Timezone** (`EVENT_TIMEZONE`) — shown only when it differs from the device zone -- **URL** — ~~tappable link card~~ **cut**: `CalendarContract` exposes no - `Events.URL` column (only `CUSTOM_APP_URI`, an originating-app deep-link). - URLs are instead surfaced by linkifying the description text -- **Access level / class** (private / confidential) — small chip (optional, trivial) - -All of the above shipped in v0.6.0 (2026-06-11). - -Deliberately out of v0.6: -- Recurrence exception / modified-occurrence badges — `Instances` already - resolves correct per-occurrence times for display; this only matters for - editing, so it folds into v2 -- `CATEGORIES`, `ATTACH` — not reliably exposed by `CalendarContract` - (provider limitation, not our choice) - -## v1.0 — First Public Release — shipped 2026-06-11 - -All V1 features shipped, polished, on F-Droid. Read-only calendar. Cut directly -after v0.6 (full event read) plus the onboarding-screen polish pass. - -### Polish backlog (pre-1.0) -- ~~Redesign the initial grant-access (permission) screen~~ — **done** - (Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0) - -## v2.0 — Write Support (complete, shipped 2026-06-11) - -Delivered in four releasable slices (plan: -`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a -guide here, not a contract — scope per slice is decided as we go. - -| Version | Milestone | Status | -|---|---|---| -| v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | complete (shipped 2026-06-11) | -| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) | -| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) | -| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) | -| v1.4 | Reminder notifications — see below | complete (shipped 2026-06-11) | -| v2.0 | Conflict dialog, polish pass (store copy refresh, F-Droid screenshots), release | complete (shipped 2026-06-11) | - -v2.0 scope was re-cut on 2026-06-11, after v1.4: -- **Occurrence edit** already shipped early, in v1.3. -- **Quick-add** is **cut from scope**: the full form already opens prefilled - (visible day, last-used calendar, optional fields hidden), so the sheet - would only save one screen transition while adding a second create-surface - to maintain. Revisit only if real-world feedback says creation feels heavy. -- **Calendar switching while editing** moves to the v3 backlog (sync-adapter - minefield: `CALENDAR_ID` is sync-adapter-owned, AOSP locks the field; an - honest implementation is copy+delete like Google Calendar, with sync-identity - and attendee side effects). -- **Conflict dialog** stays (plan 03, decision 5): on save, compare against - the row as it was when the form loaded; on external change, ask - overwrite / discard. Closes the silent-clobber gap on synced calendars. - -## v1.4 — Reminder Notifications - -**Essential**, not nice-to-have: Calendula targets users for whom it is their -*only* calendar app, so reminder delivery can't be delegated to Google/OEM -Calendar. The calendar provider schedules reminders and broadcasts -`android.intent.action.EVENT_REMINDER`, but it does **not** post the visible -notification — a calendar app must. We become that app (the Etar model). - -Scope: -- Manifest-registered `BroadcastReceiver` for `EVENT_REMINDER` - (data scheme `content://com.android.calendar`) — wakes us at reminder time, - no foreground service. -- Read `CalendarContract.CalendarAlerts` / `Reminders`, filter to - `METHOD_ALERT` / `METHOD_DEFAULT` (skip `METHOD_EMAIL`); post on a dedicated - notification channel; tap opens event detail. -- `POST_NOTIFICATIONS` runtime permission (API 33+) — requested in onboarding. -- Onboarding step: (a) request `POST_NOTIFICATIONS`, (b) in-app reminders - toggle, **default ON**, with copy warning that a second calendar app with - notifications on will cause duplicate reminders. Mirrored into Settings - (reversible). - -Deliberately deferred (add only if needed): -- Snooze / dismiss notification actions (Etar has them) -- Battery-optimization exemption prompt for delivery reliability - -## v2.1 — Month event grid + drawer view tabs (shipped 2026-06-15) - -- Month grid shows real events as continuous multi-day bars (not just dots) -- View section in the navigation drawer to switch Month / Week / Day -- Fix: text cursor no longer jumps in event text fields - -## v2.2 — Tap-to-create + local calendar management (shipped 2026-06-16) - -- Tap an empty slot in day/week → create form prefilled with that day + the - tapped hour (snapped to the hour, 1 h long) -- Local (device-only) calendar management in a full-screen editor from - Settings → Calendars: create / rename / recolor / delete, with name, - pastel-previewed colour, and description (stored in `CAL_SYNC1`) -- Synced calendars listed read-only, grouped by account, each with a - per-account "manage in source app" deep-link (resolved from the account's - authenticator — DAVx5/ICSx5/…) + an add-account shortcut -- Shared `InlineTextField` extracted to `ui.common` (event form + calendar - editor share one input style) - -## v2.3 — Material 3 grouped-list redesign (shipped 2026-06-16) - -A structural + visual pass adopting one shared blueprint (modelled on the ReFra -gallery app) across Settings, the calendar manager and the navigation drawer. - -- Shared `ui/common/GroupedList.kt`: `CollapsingScaffold` (a `LargeTopAppBar` - whose title collapses on scroll) + `GroupedRow` (Position-based corner - grouping, press-animated corners, `selected` + `minHeight` knobs). -- Settings: category hub with About card on top and sliding sub-pages - (Appearance / New event form / Notifications); theme/week-start/language - pickers moved from `DropdownMenu` to OptionCard dialogs; token-based icon - chips; `ic_gitea.xml` for the About "Source" button. -- Calendar manager + drawer restyled to match; shared `CalendarColorChip`; - drawer scrolls as one with the active view highlighted. -- Cards use `surfaceContainerHigh` for readable contrast. -- Donate button on the About card deferred (target TBD). - ---- - -# Backlog (theme-based, post-v2.1) - -The old v3.0 / "daily-driver polish" / "Locations & People" lists are -consolidated here by theme. Within a group, **(in progress)** / -**(next)** mark what is being or about to be worked; everything else is an -approved-but-unscheduled idea unless tagged **(idea)** / -**(go/no-go)** / **(rejected)**. Order across groups is not a commitment. - -## Near-term sequence (ranked, 2026-06-16) - -The theme groups below are the full menu; this is the committed *order* for -the next stretch. Ranking favours finishing the current create/edit + calendar -arc before opening new fronts, then cheap-relative-to-value items and ones that -unblock a later item. Order is a plan, not a contract — revisit after each lands. - -**Tier 1 — finish the current arc (create/edit + calendars)** -1. Tap-to-create in day/week *(shipped v2.2.0)* — prefilled create from an empty slot -2. Local calendar management + "manage in source app" deep-links *(shipped v2.2.0)* -3. ~~Settings redesign & restructure~~ *(shipped v2.3.0 — grew into the full - grouped-list blueprint across Settings + calendars + drawer; see "v2.3" - above)* -4. ~~Per-event color~~ *(shipped v2.4.0)* — palette calendars write - `EVENT_COLOR_KEY` (sync-safe); local/opted-in calendars write a raw - `EVENT_COLOR`; off-by-default setting for no-palette synced calendars -Tier 1's create/edit + calendars arc is effectively closed. **Duplicate event** -was deprioritised (2026-06-17) as low-importance and dropped to the bottom of -the sequence; the next item is now **Jump-to-date** (formerly Tier 2). - -(Tier 2+ numbering below shifts accordingly; ranking unchanged.) - -### Settings redesign & restructure *(shipped v2.3.0)* - -The original scope below is kept as a record; the implementation expanded from a -sub-screen restructure into the shared grouped-list blueprint (see "v2.3" above). - -The settings screen has grown into a flat vertical scroll of divider-separated -sections (Appearance, Event form, Notifications, Calendars, Language, About) and -will keep accreting rows (per-event-color defaults, default reminder, more -calendar entries are all queued). It needs structure before it gets unwieldy. - -**Decided (2026-06-16): sub-screens**, not flat-but-carded. The top level -becomes a category list; each category opens its own destination. More -M3-idiomatic for a settings surface that will keep growing, and it mirrors the -existing Calendars row, which already navigates out to its own screen. - -Structure — top-level settings list → category destinations: -- **Appearance** → theme, dynamic colour, week start -- **Event form** → the 6 default-field toggles + the hint text -- **Notifications** → reminders toggle (POST_NOTIFICATIONS flow stays) -- **Calendars** → already its own screen (`CalendarsScreen`); just becomes a - peer category row, no change to that screen -- **Language** → single control; keep as a top-level row that opens an - OptionCard directly (a whole sub-screen for one choice is overkill) -- **About** → kept inline on the top-level list as a card (read-only info, - not worth a navigation hop). Card layout, top → bottom: - - **Identity** — app logo + name "Calendula", with "by Jean-Luc Makiola" - as a subtitle beneath the name - - **Action buttons** (small, button-styled, sit in a row): - - **Source** — Gitea logo, opens the repo (`about_source_url`) - - **License** — opens the LICENSE file on Gitea - - **Donate** *(tentative)* — sits next to Source; target TBD (decide - before building: Liberapay / Ko-fi / Gitea sponsor / etc.) - - **Version** — small version number at the bottom of the card - -Scope: -- **Navigation** — add the settings sub-screen destinations alongside the - existing settings/calendars routes in `CalendarHost`; back pops to the - settings list (mind the existing `BackHandler` that guards against falling - through to the activity). -- **Fix the dialog-pattern violation** — theme, week-start and language use - `DropdownMenu`; the project default is the full-width tonal OptionCard modal - (radio/dropdown/text-list dialogs are banned, see - `option-card-modal-style-default`). Migrate these selectors to OptionCard. -- **Visual pass** — top-level category rows with leading icons; consistent - spacing and row affordances aligned with the event-form card design system. - -Out of scope (no new settings *features* here) — this is a structure + style -pass on the existing controls; new toggles ride in with their own features. - -**Tier 2 — navigation & daily-driver completeness** -5. ~~Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap~~ *(done, v2.5.0)* -6. ~~Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget~~ *(done, v2.5.0)* - -**Tier 3 — platform reach (depends on Tier 2)** -7. ~~Home-screen widget — built on the agenda data source from #6~~ *(done, v2.5.0 — agenda + month widgets)* -8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; ~~quick-settings tile~~ *(done, v2.8.0 — "New event" QS tile)* - -**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)* -9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global - default reminder **+ per-calendar override**, bundled with battery-exemption - hardening. Full sketch in "Reminders — defaults & delivery reliability" below. -10. **The `.ics` engine — export + import** *(shipped v2.7.0, 2026-06-18)* — one - hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`), - four surfaces all shipped: single-event share + whole-calendar backup - (export), open-`.ics`→form + whole-calendar bulk import (import). Closed the - device-local-calendar data-loss gap (#10/#11 merged here). Built as two - sequential branches: `feat/ics-export` (write side + UID-on-create precursor) - then `feat/ics-import` (parser, restore, dedup by UID). Import is - liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / `RECURRENCE-ID` - / guest lists it can't model. Plans: - `docs/superpowers/plans/2026-06-18-05-ics-export.md` + `…-06-ics-import.md`. -11. **Snooze / dismiss notification actions** *(merged into release/v2.8.0)* — - followed the `.ics` work; inherits v2.6's deferred exact-alarm/WorkManager - decision (snooze must re-fire an alarm). - -**Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)* -12. **Attendee editing** *(shipped — merged into release/v2.8.0, `feat/attendee-editing` - MR !29 / commit `b0f34ff`, 2026-06-22)* — closed the last big read-only gap in - the event model: attendees were already *read* (queried, mapped, shown on the - detail screen since v0.6) and are now *writable* from the form. An attendees - section on `EventEditScreen` / `EventForm` adds by typed email **or** the - no-permission contact picker, edits/removes rows, and sets role (required / - optional) — writing `CalendarContract.Attendees` rows on insert + dirty-checked - update, mirroring the reminders-diff pattern. The sync-adapter *invitation* - caveat was **settled record-only** (Calendula never sends invites; the backend - decides delivery) — full sketch + decision in "Attendee editing" under Locations - & People below. - -**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)** -- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage) -- Locations & People — the no-permission contact pickers already shipped (location v2.8.0, attendee email v2.8.0); what remains gated is OSM autocomplete (needs INTERNET) -- Move event to another calendar — sync-adapter minefield (copy+delete model) - -**Bottom — deprioritised, not important** -- Duplicate event (detail action → prefilled create form) — moved here - 2026-06-17; cheap but low value, pick up only if asked - -**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts. -Pulled in opportunistically, not sequenced. - -Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics` -export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected), -and Tier 5 #12 — attendee editing — shipped in release/v2.8.0, closing the last -read-only gap in the event model. v2.8.0 also cleared most Tier 2/3 leftovers — -full-text search, the "New event" Quick Settings tile, and the now-line all -shipped there. **No tier work is currently committed as next;** the remaining -candidates are unscheduled theme-group ideas (pinch-to-zoom, tablet/foldable, -accessibility pass) plus the gated go/no-go items. - -## Navigation & views - -- ~~Tap an empty slot in day/week → create form prefilled with that - date+time, snapped to the hour~~ **shipped v2.2.0** (long-press variant - not added — single tap covers it) -- Agenda view (fourth view: upcoming events grouped by day; also the - natural data source for a future widget) -- Jump to date — drawer date picker (un-cut from V1) -- ~~Current-time "now" line in day/week~~ **shipped v2.8.0** -- Week numbers in the **month** grid — **rejected** (owner decision): clutters - the view and shrinks the day cells; the badge stays week-view-only. -- Pinch-to-zoom time scale in day/week -- Tablet / foldable layouts *(was v3.0)* -- ~~Full-text search~~ **shipped v2.8.0** — for a daily driver with real event - history, finding an event is core completeness, not optional. - -## Event editing & creation - -- Duplicate event (detail action → prefilled create form) -- ~~**Per-event color**~~ *(shipped v2.4.0)* — `EVENT_COLOR` / `EVENT_COLOR_KEY` - from the calendar's color list (`Colors` table, `TYPE_EVENT`), OptionCard - picker in the form, falling back to the calendar color when unset. Reused the - color-picker component and palette plumbing from local calendar management and - finished the create/edit theme. - -## Calendars & accounts - -- ~~Create / manage local (device-only) calendars~~ **shipped v2.2.0** — - name + color + description; rename / recolor / delete the calendars the app - owns. Inserted under `ACCOUNT_TYPE_LOCAL` as a sync adapter; description in - `CAL_SYNC1`. Full-screen "Calendars" editor reached from Settings. -- ~~Per-calendar "manage in source app" deep-link~~ **shipped v2.2.0** — for - synced calendars, open the app the calendar actually came from based on - its `ACCOUNT_TYPE` (DAVx5 `bitfire.at.davdroid`, Google `com.google`, - …); fall back to system account/sync settings. Plus an "add account" - entry into system Accounts. Honest boundary for remote calendars. -- **Remote calendar create/edit** *(go/no-go)* — creating a CalDAV - collection (`MKCALENDAR`) or a Google calendar means an in-app sync - client: **INTERNET permission, credential storage, the full server - round-trip** — i.e. re-implementing DAVx5. DAVx5 exposes no public - intent to delegate the create to it. Cosmetic local edits (color/name) - to an existing synced row are possible but don't propagate to the server - and may be overwritten on next sync — not promised. Same explicit - go/no-go gate as the OSM/INTERNET item below. -- Move event to another calendar (copy+delete model with a consequences - warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)* -- ~~**Local-calendar backup / export** *(Tier 4 #10)*~~ **shipped v2.7.0** — - device-only (`ACCOUNT_TYPE_LOCAL`) calendars had no sync and therefore no - backup. Settings → Calendars → Export writes every event to a user-chosen - `.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by - UID). Closed the silent data-loss gap. - -### Disable a calendar in-app *(captured 2026-06-25)* - -A second, heavier visibility level **above** the existing per-view filter. Today -the drawer's calendar filter (`hiddenCalendarIds` in `CalendarPrefs`) only hides -a calendar's *events* from the month/week/day/agenda views — the calendar itself -still clutters the drawer filter list, the event-form calendar picker, and the -import target picker. "Disable" removes a calendar from the app's surfaces -entirely; "hide" stays the lightweight, frequently-toggled control. - -**Two-level model (both kept):** -- **Hidden** (existing) — `hiddenCalendarIds`; a quick per-view checkbox in the - drawer. Toggles events on/off in the views; the calendar stays listed - everywhere. Operates only over the *enabled* calendars. -- **Disabled** (new) — the calendar is gone from the app: not in the drawer - filter list, not in the event-form picker, not in the import picker, and its - events never appear (it's gated out of `instances()` like a hidden one). It - remains visible **only** in Settings → Calendars, where the enable/disable - toggle lives, so it can be brought back. - -**Storage — app-side (DataStore), mirrors the hidden set.** Add -`disabledCalendarIds: Set` + `setDisabledCalendarIds(...)` to -`CalendarPrefs` (comma-separated string key, same shape as `hiddenCalendarIds`). -**Does not touch** `CalendarContract.Calendars.VISIBLE` / `SYNC_EVENTS` — purely -a Calendula-local preference, so other calendar apps are unaffected and the sync -adapters stay out of it (privacy-clean, reversible). - -**Where the disabled set is applied:** -- `CalendarRepositoryImpl.instances()` + `searchEvents()` — exclude - `calendarId ∈ (hidden ∪ disabled)` so a disabled calendar's events never show - and aren't searchable. (`repository.calendars()` itself stays unfiltered/raw — - the screens that need everything still get everything.) -- `FilterViewModel.state` — drop disabled calendars from the drawer filter list - (you can't hide/show what's disabled). -- `EventEditViewModel.writableCalendars` — exclude disabled, so you can't create - into a calendar you've removed from the app. Handle the last-used-calendar - preselect falling on a now-disabled calendar (fall back to first enabled - writable). -- `ImportViewModel` — exclude disabled from the import target list. -- `CalendarsScreen` / `CalendarsViewModel` — the **only** surface that lists - disabled calendars; add `setDisabled(id, Boolean)` and a per-calendar toggle. - -**UI — Settings → Calendars (no new tab).** Add an enable/disable control to each -row on the existing `CalendarsScreen` (both the local and the synced/read-only -groups — disabling is an app-side view choice, independent of write access). -Disabled rows render visibly de-emphasised (dimmed) but keep the toggle so -they're re-enableable. Follow the project dialog/list conventions (M3 grouped -list, `option-card-modal-style-default` if a confirm/selection surface is needed). - -**Decided behaviour:** -- All calendars disabled → views show the existing empty state. -- A disabled calendar that still holds events: events simply vanish from - views/search until re-enabled (no data touched — it's a filter, not a delete). -- Deep links / notifications pointing at an event in a disabled calendar still - open its detail screen — detail is a direct id lookup, not an `instances()` - query, so disabling never strands an existing link. - -## Reminders — defaults & delivery reliability *(shipped v2.6.0; built on `feat/default-reminders`)* - -Two themes bundled because both are "make reminders trustworthy" — the core of -the "Calendula is your only calendar app" promise. - -**Built in this slice (A + the safe half of B):** global timed default reminder -+ a **separate all-day default** (day-scale lead times) + per-calendar override -(timed events), applied on create with manual-edit / calendar-switch / all-day- -toggle handling; three pickers + per-calendar override list in Settings → -Notifications; battery-optimisation exemption row (status + system deep-link, no -extra permission). `resolveDefaultReminder` + prefs round-trips unit-tested. -Resolution model: all-day events use the all-day global default outright; -per-calendar overrides govern timed events only. Reviewed (8-angle), fixes -applied: form-reset state race, label-fn consolidation with the detail screen, -inline wrapper + single combined flow read. - -**Deliberately deferred (documented decisions, not oversights):** -- *Absolute time-of-day for all-day reminders* — the all-day default is still - minutes-before-midnight (day-scale presets), not "9am the day before" (open - decision #2's richer half). Per-calendar all-day overrides also deferred. -- *Self-scheduled alarms* — kept the existing provider-broadcast architecture - (open decision #1). The battery exemption is the reliability lever; no - `AlarmManager`/`USE_EXACT_ALARM` subsystem was added. -- *Test-reminder diagnostic* and *battery prompt inside onboarding* — the - exemption lives only in Settings for now (onboarding flow untouched to keep - the change reviewable). - -### A. Default reminders (global + per-calendar override) - -**No provider backing.** `CalendarContract` has no column that auto-applies a -default reminder per calendar — Google's per-calendar defaults live server-side. -So both the global default *and* the per-calendar override are **app-side -preferences**, applied by us at event-insert time. We inherit nothing from the -synced calendar. - -- **Storage (DataStore):** - - `defaultReminderMinutes: Int?` — global default; `null` = "no reminder". - - `defaultAllDayReminderMinutes: Int?` — separate all-day default (all-day - reminders are expressed as minutes before midnight / day-before-at-time, not - minutes before a start instant — they need their own value). - - `perCalendarReminderOverride: Map` — keyed by calendar id; - **absent key = inherit global**, explicit `null` = "no reminder for this - calendar". (Same for an all-day override map if we want per-calendar all-day.) -- **Apply on create:** a fresh event prefills its reminders list from - override-or-global for the preselected calendar. Changing the calendar in the - form re-applies the *new* calendar's default **only if the user hasn't manually - edited the reminders** — track a dirty flag, mirroring the per-event-color - reset pattern (v2.4). -- **Edit semantics:** defaults apply to **new events only**; never rewrite - reminders on existing events on open or on calendar-switch-during-edit. -- **Settings UI (Notifications sub-page):** - - Global default via OptionCard (None / at time of event / 5 / 10 / 15 / 30 min - / 1 h / 1 day / custom), plus the separate all-day default. - - Per-calendar overrides: a row per writable calendar (in the Calendars screen - or a Notifications subsection), each opening the same OptionCard with a - leading **"Use global default"** option. - -### B. Delivery reliability (exact alarms + battery) - -The provider broadcasts `EVENT_REMINDER`, but on modern Android (Doze / OEM -battery managers) delivery can be silently delayed or dropped. v1.4 deferred this; -it directly undermines the feature's premise, so it rides in here. - -- **Exact alarm — decision first:** trust the provider broadcast, or - self-schedule via `AlarmManager.setExactAndAllowWhileIdle` for reliability? - If we self-schedule, declare `USE_EXACT_ALARM` (API 33+, auto-granted for - calendar/alarm-category apps, F-Droid-clean) with a `SCHEDULE_EXACT_ALARM` - fallback for API 31–32 (user-revocable → settings deep-link prompt). -- **Battery-optimization exemption:** a *soft, optional* prompt via - `ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (settings deep-link — never the - auto-grant intent), honest copy: "Android may delay reminders to save battery; - exempt Calendula for on-time delivery." Shown once after the existing - `POST_NOTIFICATIONS` onboarding step, reversible in Settings → Notifications. -- **Diagnostics:** a "send a test reminder in 1 minute" button in Notifications - settings so users can verify delivery on their specific OEM (Samsung / Xiaomi - are notorious for suppressing it). - -### Decisions made (as shipped in v2.6.0) - -1. **Provider broadcast kept** — did not self-schedule via `AlarmManager`; the - battery-optimisation exemption is the reliability lever (simplicity + battery - cost won over the exact-alarm subsystem). -2. **All-day reminders = minutes-before-midnight** (day-scale presets); absolute - time-of-day ("9am the day before") deferred — see the deferred list above. -3. **Per-calendar overrides live in the Notifications sub-page** (override list), - governing timed events only. - -### Round two - -- ~~Snooze + dismiss actions on the notification~~ *(shipped in release/v2.8.0, - Tier 4 #11)* — snooze re-fires a snooze-only exact alarm. - -## Sharing & interop - -- ~~Share event as .ics + open/receive .ics into a prefilled create form~~ - **shipped v2.7.0** — single-event share from detail; opening an `.ics` with one - event prefills the create form, many events opens a bulk import (dedup by UID) -- ~~ICS file import~~ **shipped v2.7.0** — covered by the open/receive `.ics` - flow above (single → form, many → bulk import) - -## Platform & launchers - -- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets -- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0** -- ~~"New event" Quick Settings tile~~ **shipped v2.8.0** - -## Quality & reliability - -- **Accessibility pass** — TalkBack content descriptions across all screens, - dynamic-type / large-font reflow, touch-target audit. Quality bar for an - F-Droid app; nothing tracks it yet. -- **Reminder delivery reliability** — exact alarms + battery-optimization - exemption; specced in the "Reminders — defaults & delivery reliability" slice - above (Tier 4 #9). - -## Locations & People *(go/no-go, captured 2026-06-11)* - -Beyond classic calendar-client scope; discussed, deliberately not planned -in detail yet: - -- ~~**Contact address picker** for the location field via the system picker - (`ACTION_PICK`)~~ **shipped v2.8.0** — one-shot, needs no READ_CONTACTS, fits - the privacy story. The same no-permission mechanism was then reused for the - attendee email picker (v2.8.0). -- **OSM address autocomplete** in the location field (type "Brandenburger - Tor" → tap suggestion → resolved address inserted). Backend would be - Photon (Nominatim's public policy forbids autocomplete). **Requires the - INTERNET permission** — first dent in the "no network access" promise; - if built: opt-in (off by default), honest copy, configurable endpoint - for self-hosters, onboarding footnote + F-Droid copy reworded. This - trade-off is an explicit go/no-go decision before any work starts. -- **Inline contact suggestions** while typing (needs READ_CONTACTS) — only - if the picker proves clunky. -- **Attendee editing** *(promoted out of this gated bucket 2026-06-22 — now - Tier 5 #12, high-importance; the no-permission typed-email path is not an - INTERNET/contacts call)*. See the "Attendee editing" sketch below. - -### Attendee editing *(Tier 5 #12, opened 2026-06-22)* - -The last read-only gap in the event model: attendees are read & shown on the -detail screen (since v0.6) but the form can't write them. Make guests editable. - -- **Read side already done:** `Attendee` domain model + status/relationship/type - enums, `queryAttendees` + `EventDetailMapper.toAttendee` (with tests), and the - attendees `DetailCard` + `AttendeeRow` in `EventDetailScreen`. Nothing to add - there. -- **Write side — SHIPPED (`feat/attendee-editing`, commit b0f34ff, 2026-06-22):** - `attendees` on `EventForm`; a Guests section in `EventEditScreen` rendered as an - inline grouped list — each guest a tonal card (avatar, name/email, tappable - Required/Optional role chip, remove), the trailing card an inline email field - (type → Done commits). Persisted by `reconcileAttendees` diffing the provider's - `CalendarContract.Attendees` rows on insert + dirty-checked update (new guests = - `RELATIONSHIP_ATTENDEE` / `STATUS_INVITED`; kept rows keep their status, only the - required/optional type updates; organizer/resource/no-email rows untouched). - Reminders restyled to the same grouped-list pattern in passing. Needs only the - existing `WRITE_CALENDAR` — no new permission. -- **Name on manual add — SHIPPED via a contact picker** (2026-06-22). The inline - field is email-only (the quick path); the add row also has a **"from contacts" - button**: `ACTION_PICK` on `ContactsContract.CommonDataKinds.Email.CONTENT_URI` → - the system Contacts app returns a URI to the picked email row, queried for - `Email.ADDRESS` **and** the contact `DISPLAY_NAME`, so a picked guest gets both - email and name in one tap. The result Intent grants temporary read access, so - **no `READ_CONTACTS` permission** — one-shot and user-driven, same no-permission - mechanism as the location address picker above. Self/organizer rows stay - non-editable. -- **Invitation behavior — DECIDED 2026-06-22: record-only, all writable - calendars.** Calendula has no INTERNET and never sends an invitation itself; it - only writes `Attendees` rows. Whether a guest is notified is decided downstream: - local calendars notify no one (no sync); CalDAV/DAVx5 PUTs the `ATTENDEE` lines - and the *server* decides iMIP delivery; Google's sync adapter pushes the change - and Google decides (third-party attendee writes are historically unreliable - there). Editing is allowed on **any writable calendar** — not gated to local. - - **Honest, backend-aware copy is mandatory** (this is the whole point of the - decision): on a synced calendar show "Calendula doesn't send invitations — - your calendar account may email guests when it syncs"; on a local calendar - show "Stored on this device. No one is notified." - - Calendula must **not fabricate an ORGANIZER** or otherwise fake scheduling - state to coax a send — it writes the guest list faithfully and leaves - scheduling entirely to the backend. - - The optional "send an .ics invite via your email app" delegate (`ACTION_SEND`, - still no INTERNET) was considered and **deferred** — revisit only if users ask - to notify guests explicitly. -- **Out of scope (for now):** RSVP/your-own-response editing, free/busy lookups, - resource booking — all carry server round-trips or richer sync semantics. - -## Consciously rejected - -- Travel time / weather / smart suggestions (network, core-promise conflict) -- Natural-language quick entry (high effort, locale-fragile; the prefilled - form already covers fast entry) -- Quick-add sheet (the prefilled full form already covers it — cut in v2.0) -- Drag & drop rescheduling in day/week — **rejected** (owner decision, - reaffirmed 2026-06-22): not wanted. Rescheduling stays via the edit form. diff --git a/.planning/STATE.md b/.planning/STATE.md deleted file mode 100644 index a2c9e67..0000000 --- a/.planning/STATE.md +++ /dev/null @@ -1,177 +0,0 @@ -# Calendula — Current State - -*Last updated: 2026-06-22* - -## Status - -**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11. -**Phase:** post-2.x theme-based backlog work (organised in `ROADMAP.md`). -**Latest released tag: v2.7.5.** The whole Tier 4 (reliability/data-safety/ -interop) arc is now done or in flight: -- v2.4.0 per-event colors (2026-06-17) -- v2.5.0 jump-to-date, Agenda view, agenda + month home-screen widgets, "New - event" launcher shortcut (2026-06-17) -- v2.6.0 default reminders (global + per-calendar override, all-day default, - battery-exemption row) + system per-app language (2026-06-18) -- v2.7.0 **`.ics` engine** — single-event share, local-calendar backup export, - open/receive `.ics` (single → form, many → bulk import, dedup by UID) - (2026-06-18) -- v2.7.1–v2.7.5 — crash-reporting + F-Droid reproducible-build hardening + fixes - -**Next release `release/v2.8.0` (integration branch, not yet cut to main):** -holds crash reports via the public Codeberg tracker (MR !27) + reminder -snooze/dismiss notification actions (MR !28). Version bump to 2.8.0 happens at -release-cut. - -## Progress - -- [x] Design spec written and committed (`docs/superpowers/specs/2026-06-08-calendar-app-design.md`) -- [x] V1 design decisions resolved (App name "Calendula", icon, seed color) -- [x] Plan 01 written and executed — foundation lands (theme, icon, i18n, Hilt, DataStore, CI green) -- [x] Plan 02 written and executed — data layer + permission flow + debug screen -- [x] Month view (S1) — 6-week grid, event dots, today marker, swipe nav, three states (replaces debug screen) -- [x] Week view (S2) — time schedule with overlap-resolved lanes, all-day strip, swipe nav, three states -- [x] Day view (S3) — single-column slice reusing the week layout -- [x] View-switcher (M1) wired — cycles Month ↔ Week ↔ Day -- [x] Event-detail screen (S4) — full-screen, humanized recurrence -- [x] Filter sheet (M3) — per-calendar visibility, grouped by account, persisted, applied centrally in the repository -- [x] Settings (M4) — appearance (theme, dynamic colour, week start), language (per-app locales), about -- [~] Jump-to-date (M2) — **cut from scope**; "Today" half shipped in v0.5, date-picker dropped -- [x] Full event read (v0.6) — reminders, status, availability, access level, - attendee role + self-response, foreign timezone, and linkified description - URLs in the detail view; new domain enums + mapper unit tests. (A dedicated - URL field was cut — no `CalendarContract` column backs it.) - -- [x] v1.1 write foundation — `WRITE_CALENDAR` (onboarding asks READ+WRITE, - only READ gates; contextual upgrade for v1.0 installs), read-only-calendar - detection (`CALENDAR_ACCESS_LEVEL` → `canModifyContents`, actions hidden for - WebCal/birthday calendars), delete from the detail screen (recurring: - "only this event" via cancelled exception / "all events in the series"), - repository + mapper tests - -- [x] v1.2 create event — full-screen `EventEditScreen` (title, all-day, - M3 date/time pickers with duration-preserving start moves, writable-only - calendar picker preselecting the last-used calendar, location, description), - "+" FAB on all three views prefilled with the visible day, `insertEvent` - with provider-correct all-day normalisation (UTC midnights, exclusive end), - domain/mapper/repository tests - -- [x] v1.3 edit event (shipped 2026-06-11) — `EventEditScreen` reused for - edit (detail-screen Edit action, `canModify`-gated, contextual WRITE - upgrade), dirty-checked partial `update` on the Events row (recurring: - series DTSTART moves by the user's delta, DURATION instead of DTEND), - reminder diff by minutes (kept rows keep their method), simple recurrence - picker (FREQ/INTERVAL/UNTIL/COUNT; complex RRULEs preserved verbatim and - shown humanized), `EventFormField.Recurrence` incl. settings default, - recurrence also available on create; domain/mapper/repository tests. - Review round 1: weekly BYDAY day-toggles in the custom picker ("every week - on Mon+Fri"). Review rounds 2–4: occurrence edit pulled forward from v2.0 - and made three-way like delete ("this" = exception row via - `CONTENT_EXCEPTION_URI`, "this and following" = series split, "all" = - series update); delete equally three-way (truncation via RRULE UNTIL); - the edit-scope question moved to save time (Google model) — dirty - recurring saves park in `SaveUiState.AwaitingScope`, a changed rule drops - the "only this event" option - -- [x] v1.4 reminder notifications (shipped 2026-06-11) — exported - `EVENT_REMINDER` receiver → `CalendarAlerts` (SCHEDULED & due) → - dedicated channel, tap opens detail (singleTop deep link); best-effort - FIRED marking; one-time onboarding step requesting `POST_NOTIFICATIONS` - with duplicate-reminders warning; Settings mirror. Provider only fires - `METHOD_ALERT` rows (AOSP-verified), so email reminders never reach us - -- [x] v2.0 conflict dialog + store polish (shipped 2026-06-11 as v2.0.0) — - `EditSnapshot` compare on save (overwrite/discard; deleted → close), - quick-add cut, calendar-switch → v3 backlog; F-Droid/README copy - refreshed, fastlane screenshots DE+EN captured on-device - -- [x] v2.1 (shipped 2026-06-15) — month grid shows real events as - continuous multi-day bars; navigation-drawer View section - (Month/Week/Day); cursor-jump fix in event text fields - -- [x] v2.2 (shipped 2026-06-16) — tap an empty slot in day/week to create - (prefilled with that day + tapped hour, snapped to the hour); local - calendar management in a full-screen editor from Settings → - Calendars: create/rename/recolor/delete device-only calendars - (`ACCOUNT_TYPE_LOCAL`, sync-adapter insert) with name, pastel-previewed - colour, and description (stored in `CAL_SYNC1`); synced calendars listed - read-only grouped by account with a per-account "manage in source app" - deep-link (resolved from the account's authenticator: DAVx5/ICSx5/…) and - an add-account shortcut. Shared `InlineTextField` extracted to `ui.common` - -- [x] v2.3 settings/calendars/drawer redesign (shipped 2026-06-16) — adopted a - shared Material 3 grouped-list blueprint, modelled on the ReFra gallery app - and extracted to `ui/common/GroupedList.kt` (`CollapsingScaffold` with a - `LargeTopAppBar` exit-until-collapsed title; `GroupedRow` with Position-based - corner grouping, press-animated corners, `selected` + `minHeight` knobs). - - Settings: category hub (About card on top → version mark at the foot) with - sliding sub-pages (Appearance / New event form / Notifications); token- - based icon chips; theme/week-start/language pickers migrated from - `DropdownMenu` to OptionCard dialogs. New `ic_gitea.xml` (Simple Icons, - verbatim path) for the About "Source" button; en+de strings. - - Calendar manager: same collapsing scaffold + grouped rows; shared - `CalendarColorChip` (neutral chip, pastelised calendar glyph). - - Navigation drawer: branded header, grouped View switcher (active view - highlighted via `secondaryContainer`), the filter list restyled to - grouped rows with a trailing checkbox; the whole drawer scrolls as one. - - Cards use `surfaceContainerHigh` for readable contrast against `surface`. - - Donate button on the About card deferred (target still TBD). - -- [x] v2.4 per-event color (shipped 2026-06-17) — an optional "Color" field in - the event form. Read/render already resolved `EVENT_COLOR` with a calendar - fallback; this adds the write side and the picker. Palette-backed calendars - (Google, some CalDAV) pick from the account's `Colors` (`TYPE_EVENT`) and - write `EVENT_COLOR_KEY` so the color round-trips through sync; local - calendars write a raw `EVENT_COLOR` from the shared `CALENDAR_COLOR_PALETTE` - (extracted with the swatch row to `ui/common/ColorSwatchRow.kt`). Switching - calendars resets the choice (a key is account-scoped). A settings toggle - ("Allow colors on unsupported calendars", off by default) extends the raw - path to synced calendars with no palette, with an honest "may not survive - sync" warning on the picker and in Settings. Color writes flow through - insert / dirty-checked update / occurrence-exception; mapper + form tests. - -- [x] v2.5 (shipped 2026-06-17) — Agenda view (4th top-level view), - jump-to-date drawer date picker, two home-screen widgets (scrolling - "Upcoming" agenda + month grid), and a "New event" launcher long-press - shortcut - -- [x] v2.6 (shipped 2026-06-18) — default reminders: global timed default + - separate all-day default + per-calendar override (timed), applied on create - with dirty-flag handling; three pickers + override list in Settings → - Notifications; battery-optimisation exemption row (status + system deep-link, - no new permission). Plus system per-app language (Android 13+) and an - immediate-effect fix for the in-app language picker - -- [x] v2.7 (shipped 2026-06-18) — the `.ics` engine: share a single event as - `.ics` from the detail screen; back up local calendars (Settings → Calendars - → Export) to a SAF file; open/receive an `.ics` — one event prefills the - create form, many events open a bulk import into a chosen calendar (dedup by - UID, skip-and-report unrepresentable VTIMEZONE / RECURRENCE-ID / guests). - Hand-rolled serializer/parser, zero deps. Plus all-day single-day UTC fix and - a widget R8 keep-rule crash fix - -- [x] v2.7.1–v2.7.5 (2026-06-21) — launch crash fix (listener before grant), - user-controlled crash reporting, widget loading-spinner R8 keep rule, and - F-Droid reproducible-build cleanups for the official repo - -- [~] release/v2.8.0 (not yet cut) — crash reports via the public Codeberg - tracker (MR !27) + reminder snooze/dismiss notification actions (MR !28, - snooze self-schedules an exact alarm; primary delivery stays provider-broadcast) - -## Next - -1. Cut **v2.8.0** from `release/v2.8.0` (bump versionName → tag via the - merge-driven pipeline) once on-device review signs off -2. **Attendee editing** — the committed next feature (Tier 5 #12, high- - importance, opened 2026-06-22). Attendees are already read & shown on the - detail screen since v0.6; the gap is the write side — make guests editable - in `EventEditScreen` / `EventForm` (add by typed email, role, remove), - persisted by diffing `CalendarContract.Attendees`. No new permission for the - typed-email path. **Invitation behavior DECIDED 2026-06-22: record-only on all - writable calendars** — Calendula never sends (no INTERNET); honest backend-aware - copy ("your account may email guests when it syncs" on synced calendars, "no one - is notified" on local). Full sketch in `ROADMAP.md` → "Attendee editing". -3. Then: the two INTERNET go/no-go calls (OSM autocomplete, remote calendar - create/edit) and Tier 2/3 leftovers (quick-settings tile, now-line, week - numbers in month, full-text search, accessibility pass). Drag-and-drop - rescheduling is **rejected**. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5948114 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,184 @@ +# Contributing to Calendula + +Calendula is a Material 3 Expressive calendar app that lives strictly on top of +Android's `CalendarContract` — no app database, no sync stack, no network access. +That constraint shapes most review comments, so +[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) is worth skimming before you write +code. This file is the practical how. + +**[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home** — +issues, pull requests, releases. The self-hosted Gitea instance referenced in the +release docs is build infrastructure only; there is nothing to contribute there. +Be decent to the people you meet in the tracker. + +## Start with an issue + +| You want to | Do this | +|---|---| +| Add a feature | **Open an issue first** and wait for a go-ahead | +| Fix a bug | Open an issue, then a pull request | +| Fix a typo, a comment, or docs | Just open the pull request | +| Add or fix a translation | **Don't** — [use Weblate](#translations) | + +Features get an opinion before they get code: whether Calendula should do a +thing at all is the one decision a patch can't make. A feature PR that arrives +without a discussed issue may be closed unmerged even when the code is good — +please don't spend a weekend on one first. + +Bugs are more straightforward, but still start with an issue: it's what carries +the milestone and gives the changelog something to link. + +Issue templates cover bug, crash, feature and question. For a crash, let the app +do the work — **Settings → Report a problem**, or the prompt shown after a crash, +captures the stack trace and prefills the form. The report contains app, Android +and device versions plus the trace; no calendar content, no personal data. + +## Which branch to target + +Calendula releases by merging a version bump into `main`, so `main` is a release +trigger rather than a staging area. Work is assembled on release branches first. + +Once your issue has a milestone, that milestone names your branch: + +| Milestone | Target branch | +|---|---| +| `2.18.0` | `release/v2.18.0` | + +Every milestone has a matching branch. If it's somehow missing, target `main` and +mention it in the PR — it will be retargeted. Don't pick an older release branch: +they're kept after shipping, so the newest one isn't necessarily yours. + +## Translations + +**Never edit a `values-*/strings.xml` file in a pull request** — German included. +Translations are owned by a self-hosted Weblate that writes to this repository +directly, and a hand-edit is overwritten on the next sync. + +→ **[Translate Calendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)** + +Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate +picks it up and offers it to translators. Partial translations are expected and +fine — missing keys are informational. Stale and orphaned keys are not, so run + +```sh +python3 scripts/check_translations.py +``` + +before pushing. It reports those more clearly than lint's `MissingTranslation` +does. + +## Build & test + +```sh +git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git +``` + +The `floret-kit` submodule is a composite build compiled from source. An +existing clone needs `git submodule update --init --recursive`, or nothing +resolves. + +- **JDK 17** — not newer; the Android Gradle Plugin requires exactly 17. Set + `JAVA_HOME` if your default differs. +- **Android SDK** — platform 37 (`compileSdk`) and build-tools 36.0.0, located + via `ANDROID_HOME` or a gitignored `local.properties` with `sdk.dir`. If you + go the `local.properties` route the included build needs its own copy at + `floret-kit/local.properties`; `ANDROID_HOME` covers both at once and is the + easier path. + +The Gradle wrapper is checked in, so no system Gradle is needed. + +```sh +./gradlew lint test assembleDebug # roughly what CI runs +``` + +A single test class, or a pattern: + +```sh +./gradlew testDebugUnitTest --tests "de.jeanlucmakiola.calendula.domain.SimpleRecurrenceTest" +./gradlew testDebugUnitTest --tests "*SimpleRecurrence*" +``` + +CI reports one `CI` check per pull request: `lintDebug`, `testDebugUnitTest`, +`assembleDebug`, and a Trivy scan. Pull requests touching only docs, F-Droid +metadata or the licence skip the Android build and go green quickly. More detail +in [`docs/BUILDING.md`](docs/BUILDING.md). + +## The rules + +These are the ones that turn into review comments. + +1. **No network.** Calendula holds no `INTERNET` permission, and that's a + feature rather than an oversight. Anything that would need one is a product + decision before it's a patch — the crash reporter deliberately opens a + prefilled web issue instead of posting anything itself. +2. **The provider is the only database.** No Room, no cache, no local mirror of + events. `CalendarContract` is the single source of truth, which is also why + externally synced changes work for free. +3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and + views recompose from fresh provider state. Hand-patching a list after saving + appears to work, then quietly diverges from what the provider actually stored. +4. **`domain/` has no Android imports.** Models, validation, recurrence + rendering, conflict snapshots and the `.ics` codec stay pure Kotlin so they + remain JVM-testable. +5. **Tests run on the JVM.** JUnit 5 + Truth + Turbine. The seams exist for you: + fake the data source (`FakeCalendarDataSource`), and feed mappers plain maps + through `ColumnReader` instead of cursors. Instrumented tests are a last + resort, not a default. +6. **Read before touching the subtle pipelines.** Recurring writes (UNTIL vs + DURATION, exception URIs, series splits), save-conflict detection and reminder + delivery (post-before-mark) follow provider-driven rules that are documented + in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) and are not guessable from + the code alone. +7. **Don't break reproducible builds.** `vcsInfo`, `dependenciesInfo` and the AGP + metadata block are disabled on purpose so the official F-Droid repo can verify + our binary against a from-source rebuild. + `scripts/check_reproducible_release.sh` runs on every pull request, including + docs-only ones. + +## UI conventions + +Material 3 Expressive throughout, built from the system's own tokens and +components — colour-scheme tokens rather than hardcoded colours, `ListItem` for +settings rows. + +**Selection pickers are full-screen.** Every browse-style "choose one" surface +uses floret-kit's `FullScreenPicker` / `OptionPicker`; one that needs a commit or +extra action passes it through the picker's `actions` slot. The exception is the +recurring-scope chooser (*this / this and following / all*), which stays a +compact dialog — a two- or three-option decision reads better as a popup than as +a nearly empty screen. `AlertDialog` is for plain confirmations only, and radio- +or text-list dialogs aren't used at all. + +Shared UI machinery lives in the `floret-kit` submodule and has +[its own contributing guide](https://codeberg.org/jlmakiola/floret-kit/src/branch/main/CONTRIBUTING.md); +changing it means a pull request against that repository plus a submodule bump +here. + +## Commits & pull requests + +Conventional commits, scoped to the area you touched: + +``` +fix(calendars): keep an event's own calendar when it is switched off +feat(month): pull-to-expand the split view (#38) +docs(architecture): record what the second review pass changed +``` + +Types in use: `feat` `fix` `docs` `refactor` `style` `chore` `ci` `build` +`revert`. Reference the issue in the subject or the body. Keep commits small — +small commits revert cleanly, which matters more here than a tidy history. + +If your change is user-visible, add an entry under `## [Unreleased]` in +[`CHANGELOG.md`](CHANGELOG.md). Match the surrounding voice: entries describe +what changed *for the person using the app*, and why, not what changed in the +code. Link the issue and add its reference at the bottom of the file. It may get +reworded when the release is cut, so don't agonise over it. + +Please don't commit planning or design documents. Code, tests, architecture notes +and the changelog land; the reasoning belongs in the commit message and the +issue. + +## Licence + +Calendula is [MIT](LICENSE). By contributing you agree your changes ship under +the same licence. diff --git a/README.md b/README.md index f2e3b31..db48769 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,25 @@ without reinstalling. Or build from source — see below. ## 📚 Documentation +- **[Contributing](CONTRIBUTING.md)** — how to report, propose, and patch - **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks - **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines -- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next +- **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next + +## 🤝 Contributing + +Bug reports, ideas, and patches are all welcome on +**[Codeberg](https://codeberg.org/jlmakiola/calendula/issues)**. + +The short version: **start with an issue.** Features get a yes-or-no before they +get code, and both features and bugs are assigned a milestone whose +`release/vX.Y.Z` branch your pull request then targets. Typo and docs fixes can +skip straight to a pull request. Translations don't go through pull requests at +all — [Weblate owns them](#-translations). + +Read **[CONTRIBUTING.md](CONTRIBUTING.md)** before writing code: it covers the +workflow, the build (note the `floret-kit` submodule), and the architectural +rules a change is reviewed against. ## 🌍 Translations diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a8c3600..32ebb79 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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,9 +62,15 @@ 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). + 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. +- **`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 @@ -81,7 +87,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`. @@ -167,6 +173,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. diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 6524e94..9577701 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -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. diff --git a/docs/README.md b/docs/README.md index dddbcf7..79d7394 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. From c988e1f0283c793ad151ae56504cfdf28f0916c1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 30 Jul 2026 11:22:11 +0200 Subject: [PATCH 2/8] Fix the broken README screenshot and spell out the install channels (#97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screenshot strip linked `05-edit.png`, which doesn't exist — replaced with the real agenda screenshot, and the unused day-view shot is now shown too (six screenshots, all present). Install is now one section per channel — official F-Droid, self-hosted F-Droid repo, Codeberg release / Obtainium, Google Play (coming soon), build from source — with an overview table up front and the previously missing "Get it on Obtainium" badge in the header. The badge links to the Codeberg releases page rather than an `obtainium://` deep link, because Forgejo's markdown sanitiser strips custom URL schemes. The Play section notes that Play builds carry Google's signing key (so switching channels needs an uninstall) and asks for closed-testing testers. Also corrects two stale claims: the UI is no longer German-and-English only (nine translations exist), and the language list now says so. No issue — reported directly. Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/97 --- README.md | 85 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 70 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index db48769..e897cf8 100644 --- a/README.md +++ b/README.md @@ -18,15 +18,18 @@ Reads, writes, and reminds — on top of the system calendar, with zero network

Get it on F-Droid   +Get it on ObtainiumSupport me on Ko-fi

-Week view  -Month view  -Event detail  -Event form  -Reminder onboarding +Week view  +Month view  +Day view  +Event detail  +Agenda view  +Reminder onboarding

@@ -68,24 +71,38 @@ database, no sync stack reinvented. - Real Material 3 Expressive throughout — dynamic color (Android 12+), expressive motion and shapes, light/dark theme -- German and English UI, per-app language setting — and [open to community - translations](#-translations) +- English and German UI plus community translations (Spanish, French, Italian, + Polish, and more in progress), per-app language setting — and [open to more + languages](#-translations) - **Zero telemetry, zero analytics, no internet permission** — your data never leaves the device ## 📦 Install +Pick whichever channel you already use — they all install the same app: + +| Channel | Updates | Notes | +| --- | --- | --- | +| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup | +| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; needs the repo added once | +| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automated by Obtainium | +| [Google Play](#google-play-coming-soon) | — | Coming soon | +| [Build from source](#build-from-source) | Whenever you build | Full control | + ### F-Droid (recommended) Calendula is on the **official [F-Droid](https://f-droid.org) repository** — just search for **Calendula** in any F-Droid client, or [install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/). -### Self-hosted repo (latest builds) +F-Droid rebuilds from source on its own schedule, so a new version usually +shows up there a few days after release. -New versions are built, signed, and published to a self-hosted repository the -moment each tag lands — usually a few days ahead of the official repo, which -rebuilds on F-Droid's own schedule. Add it for the freshest builds: +### Self-hosted F-Droid repo (fastest updates) + +Every release is built, signed, and published to a self-hosted F-Droid +repository as part of the release pipeline, so it lands there first. Add it once +and your F-Droid client handles updates from then on: 1. In your F-Droid client, open *Settings → Repositories → Add* (or open the link below on your phone): @@ -100,8 +117,43 @@ rebuilds on F-Droid's own schedule. Add it for the freshest builds: 2. Refresh, search for **Calendula**, install. -Both channels share the same signing key, so you can switch between them -without reinstalling. Or build from source — see below. +### Codeberg release / Obtainium + +If you'd rather not use F-Droid at all, every release is also published on +**[Codeberg](https://codeberg.org/jlmakiola/calendula/releases)** with the +signed APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum attached — download +and install it directly. + +For automatic updates from that channel, use +**[Obtainium](https://github.com/ImranR98/Obtainium)**: *Add App* → paste + +``` +https://codeberg.org/jlmakiola/calendula +``` + +→ *Add*. Obtainium tracks the releases and prompts you when a new one appears. + +### Google Play (coming soon) + +Calendula is on its way to Google Play as an additional channel. It isn't live +yet — this section gets a link once it is. Play builds will be signed with +Google's key rather than mine, so switching between Play and any other channel +will require an uninstall. + +> **Testers wanted.** Play requires a round of closed testing before the app can +> go public, and I'm still looking for testers. If you'd like to help, email +> **[business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)** with the +> Google account address you want to use — that address is what I need to add you +> to the closed test. + +### Build from source + +The build is a plain Gradle build with no proprietary dependencies — see +**[docs/BUILDING.md](docs/BUILDING.md)** (note the `floret-kit` submodule). + +Official F-Droid, the self-hosted repo, and the Codeberg releases all share +the same signing key, so you can switch freely between them without +reinstalling. ## 📚 Documentation @@ -127,8 +179,11 @@ rules a change is reviewed against. ## 🌍 Translations -Calendula ships in German and English, and you're warmly invited to add your -language. Translations are managed on a self-hosted **Weblate**: +Calendula ships in English and German, with community translations in Arabic, +Chinese, French, Italian, Polish, Portuguese, Russian, and Spanish at varying +degrees of completeness — partial is fine, untranslated strings simply fall back +to English. You're warmly invited to add or finish your language. Translations +are managed on a self-hosted **Weblate**: **→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)** From 3fca28810b80e3e87dea948e7fae59fcb56e838b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 30 Jul 2026 11:22:23 +0200 Subject: [PATCH 3/8] Don't run the Android build for forge-housekeeping changes (#98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI already skips the Android build for docs-only pull requests, and that part works — a README-only PR reports green in ~11s. The skip-list was just too narrow to catch a realistic docs PR. PR #96 is the example: a contributing guide, docs corrections and issue templates. It ran the full lint + test + assemble four times, over exactly two files Gradle never reads — `.forgejo/ISSUE_TEMPLATE/config.yml` and `.gitignore`. Added to the list: issue templates, `.planning/`, `licenses/`, `renovate.json5`, `.gitignore`, `.gitattributes`, `.editorconfig`. It stays a skip-list rather than a build-list, so an unfamiliar path still builds by default; the workflows themselves, `.gitmodules` and `scripts/` are deliberately not skippable. The step now also prints which files forced the build, so the next "why did my docs PR build?" is answerable from the log. One latent bug alongside it: `github.base_ref` is normalised against a `refs/heads/` prefix. Arriving in full-ref form would fail the merge-base lookup and quietly degrade the guard into "always build" — the failure mode this PR is fixing, but permanently and invisibly. Verified by replaying the new pattern over real history: PR #96 and #97 now skip; the forge migration (workflows), the search fix (app code) and a Weblate translation merge still build. No issue — reported directly. Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/98 --- .forgejo/workflows/ci.yaml | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index 65831ab..834d026 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -37,14 +37,29 @@ jobs: - name: Reproducible-release invariant run: bash scripts/check_reproducible_release.sh - # Decide whether anything that affects the app build changed. Docs, - # F-Droid metadata and the licence don't, so those PRs skip the SDK + - # Gradle work below but still report a green `ci`. + # Decide whether anything that affects the app build changed. Docs, store + # metadata, licence texts and forge housekeeping don't, so those PRs skip + # the SDK + Gradle work below but still report a green `ci`. - name: Classify change scope id: scope + env: + # Deliberately a skip-list, not a build-list: a path nobody thought + # about defaults to building. Only paths the Gradle build provably + # never reads belong here — note that the workflows themselves, the + # `.gitmodules` submodule pointer and `scripts/` are *not* in it. + SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^licenses/|^\.planning/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^renovate\.json5$|^LICENSE$)' run: | set -e BASE="${{ github.base_ref }}" + # Normally the bare branch name; tolerate a full ref, which would + # otherwise make the merge-base lookup fail and quietly degrade this + # guard into "always build". + BASE="${BASE#refs/heads/}" + if [ -z "$BASE" ]; then + echo "No base branch on this event — running the full build to be safe." + echo "code=true" >> "$GITHUB_OUTPUT" + exit 0 + fi # Full (not --depth=1) base fetch so the merge-base is present even when # the PR branch forked several commits back; a shallow tip has no merge # base with a divergent branch and `git diff base...HEAD` aborts. @@ -58,11 +73,15 @@ jobs: fi CHANGED=$(git diff --name-only "$MB" HEAD) echo "Changed files:"; echo "$CHANGED" - if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then + RELEVANT=$(echo "$CHANGED" | grep -vE "$SKIP_RE" || true) + if [ -n "$RELEVANT" ]; then + # Naming them makes "why did my docs PR build for four minutes?" + # answerable from the log alone. + echo "Build-relevant changes:"; echo "$RELEVANT" echo "code=true" >> "$GITHUB_OUTPUT" else - echo "code=false" >> "$GITHUB_OUTPUT" echo "Docs/metadata-only change — skipping the Android build." + echo "code=false" >> "$GITHUB_OUTPUT" fi - name: Setup Java From 384ba39cf9fb59c3c184106a2fe759478f550c43 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 30 Jul 2026 11:26:18 +0200 Subject: [PATCH 4/8] fix(deps): update material3 (alpha) to v1.5.0-alpha24 (#91) fix(deps): update material3 (alpha) to v1.5.0-alpha24 #91 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6b13d4f..63af221 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ composeBom = "2026.05.01" # Material 3 Expressive APIs currently live only in the 1.5 alpha line. # Pin explicitly to override the BOM (which ships stable 1.4.0). # Re-evaluate when 1.5.0 stable lands. -material3 = "1.5.0-alpha21" +material3 = "1.5.0-alpha24" datastore = "1.2.1" junit = "6.1.0" junitPlatform = "6.1.0" From e95239311647b6fc135c52820bd0aab775e73745 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 30 Jul 2026 11:27:09 +0200 Subject: [PATCH 5/8] fix(deps): update test dependencies (#92) Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/92 --- gradle/libs.versions.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 63af221..afc8005 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,14 +13,14 @@ composeBom = "2026.05.01" # Re-evaluate when 1.5.0 stable lands. material3 = "1.5.0-alpha24" datastore = "1.2.1" -junit = "6.1.0" -junitPlatform = "6.1.0" +junit = "6.1.2" +junitPlatform = "6.1.2" truth = "1.4.5" androidxJunit = "1.3.0" espressoCore = "3.7.0" kotlinxDatetime = "0.7.0" kotlinxCoroutines = "1.10.2" -turbine = "1.2.0" +turbine = "1.2.1" hiltNavigationCompose = "1.3.0" lifecycleCompose = "2.10.0" androidxTestRules = "1.7.0" From 06653bf5b1d0587db6aa88854d1725b50e2c182c Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 30 Jul 2026 11:27:24 +0200 Subject: [PATCH 6/8] fix(deps): update composebom to v2026.06.01 (#95) Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/95 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index afc8005..aeb3573 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,7 @@ coreKtx = "1.19.0" appcompat = "1.7.1" lifecycleRuntime = "2.10.0" activityCompose = "1.13.0" -composeBom = "2026.05.01" +composeBom = "2026.06.01" # Material 3 Expressive APIs currently live only in the 1.5 alpha line. # Pin explicitly to override the BOM (which ships stable 1.4.0). # Re-evaluate when 1.5.0 stable lands. From 2f4e05899ab803ddf6102c5ba7203e23098cbd64 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 30 Jul 2026 11:39:06 +0200 Subject: [PATCH 7/8] Make the Obtainium badge add the app in one tap (#99) Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/99 --- README.md | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e897cf8..7cf96f7 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Reads, writes, and reminds — on top of the system calendar, with zero network

Get it on F-Droid   -Get it on Obtainium +Get it on Obtainium   Support me on Ko-fi

@@ -125,13 +125,11 @@ signed APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum attached — downlo and install it directly. For automatic updates from that channel, use -**[Obtainium](https://github.com/ImranR98/Obtainium)**: *Add App* → paste - -``` -https://codeberg.org/jlmakiola/calendula -``` - -→ *Add*. Obtainium tracks the releases and prompts you when a new one appears. +**[Obtainium](https://github.com/ImranR98/Obtainium)** — on the phone, +**[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula)**, +or do it by hand: *Add App* → paste `https://codeberg.org/jlmakiola/calendula` +→ *Add*. Either way, Obtainium tracks the releases and prompts you when a new +one appears. ### Google Play (coming soon) From d5c53df6b5626cf336f556ae58ec820db7352185 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 30 Jul 2026 13:59:43 +0200 Subject: [PATCH 8/8] Publish the release bundle to Google Play (#84) (#100) Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/100 --- .gitea/workflows/release.yaml | 155 +++++++++++++++++++++++++++++++++- .gitignore | 11 +++ Gemfile | 10 +++ docs/RELEASING.md | 64 ++++++++++++++ fastlane/Appfile | 7 ++ fastlane/Fastfile | 80 ++++++++++++++++++ 6 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 Gemfile create mode 100644 fastlane/Appfile create mode 100644 fastlane/Fastfile diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 17a4efd..cc0c0d7 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -1,4 +1,4 @@ -name: Release — F-Droid repo + Gitea/Codeberg release +name: Release — F-Droid repo + Gitea/Codeberg release + Play # A release is cut by merging a release branch into main with a bumped # versionName (see docs/RELEASING.md). This workflow reads that versionName and, @@ -9,6 +9,12 @@ name: Release — F-Droid repo + Gitea/Codeberg release # trigger. Ordinary merges (no version bump) fall through `detect` and do # nothing. # +# A trailing `play` job then uploads the App Bundle to Google Play. It is last +# and separate because Play is the only channel that can reject a good build for +# reasons the pipeline can't see, and that must not endanger a release which has +# already shipped to F-Droid and Codeberg. It skips cleanly until the +# PLAY_SERVICE_ACCOUNT_JSON secret exists. +# # A manual workflow_dispatch (from a branch) runs the re-sign-only recovery # path: it re-signs the existing F-Droid index with the repo key and re-uploads, # without building an APK or creating a release. Used for key rotation / repo @@ -203,6 +209,33 @@ jobs: if: env.IS_RELEASE == 'true' run: ./gradlew assembleRelease + # Play takes an App Bundle, not the APK, so it is a second artifact from + # the same source and the same signing config — not a repackage of the + # APK. The release key signs it, but Play only ever treats that key as the + # *upload* key: Play App Signing re-signs with Google's own key before + # delivery. A Play install and an F-Droid install therefore carry + # different signatures and cannot update each other. That divergence is a + # deliberate, documented choice (docs/RELEASING.md), not an accident. + # + # Nothing here touches the F-Droid path: the AAB is never copied into the + # repo, never attached to a release, and its build cannot change the APK + # that was already produced above. + # + # AGP embeds the R8 mapping in the bundle's BUNDLE-METADATA, so Play gets + # deobfuscated stacktraces without a separate mapping upload. + - name: Build release AAB + if: env.IS_RELEASE == 'true' + run: ./gradlew bundleRelease + + - name: Hand the AAB to the Play job + if: env.IS_RELEASE == 'true' + uses: actions/upload-artifact@v4 + with: + name: release-aab-${{ needs.detect.outputs.version }} + path: app/build/outputs/bundle/release/app-release.aab + if-no-files-found: error + retention-days: 14 + - name: Setup F-Droid Server Tools run: | SUDO="" @@ -477,3 +510,123 @@ jobs: "$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n" done echo "Published $TAG to Codeberg." + + # Google Play channel. + # + # A separate job, on purpose, running only AFTER the F-Droid publish and both + # forge releases have completed. Play is the one channel that can reject a + # perfectly good build for reasons outside the pipeline (listing rules, policy + # review, API outage, a track that needs manual promotion). Isolating it means + # such a rejection surfaces as one red job next to a release that already + # shipped everywhere else, instead of failing the workflow that publishes it. + # + # Not a `container:` job even though a fastlane image exists: act_runner does + # not provide node inside custom job containers, so JavaScript actions + # (checkout, download-artifact) can't run there. The Renovate job gets away + # with a container because its only step is a shell command. Ruby is installed + # the same way sshpass, jq and fdroidserver are in the job above. + play: + needs: [detect, release] + # workflow_dispatch is the F-Droid re-sign recovery path — it must never + # touch Play, so gate on a real release only. + if: needs.detect.outputs.is_release == 'true' + runs-on: docker + env: + VERSION: ${{ needs.detect.outputs.version }} + VERSION_CODE: ${{ needs.detect.outputs.version_code }} + # Where the bundle lands. `internal` by default so a release reaches + # testers rather than the public, and promotion to production stays a + # deliberate human action in the Play Console — the same posture as + # holding UI releases for on-device review. Override with the PLAY_TRACK + # repo variable once the flow is trusted. + PLAY_TRACK: ${{ vars.PLAY_TRACK || 'internal' }} + PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }} + # Set PLAY_DRY_RUN=true to validate the edit against the API and discard + # it instead of committing — used to rehearse the first upload. + PLAY_DRY_RUN: ${{ vars.PLAY_DRY_RUN || 'false' }} + BUNDLE_PATH: vendor/bundle + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Skip cleanly (not fatally) when Play isn't configured yet, so the rest + # of the release pipeline keeps working during setup — same contract as + # the Codeberg mirror step. + - name: Write the Play service-account key + id: key + env: + PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + run: | + set -euo pipefail + if [ -z "${PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then + echo "PLAY_SERVICE_ACCOUNT_JSON not set — skipping the Play upload." + echo "configured=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json + # Fail here, with a clear message, rather than inside fastlane: a + # mangled multi-line secret is the likeliest setup mistake. + python3 -c "import json,sys; d=json.load(open('play-service-account.json')); sys.exit(0 if d.get('type')=='service_account' else 1)" \ + || { echo "PLAY_SERVICE_ACCOUNT_JSON is not a valid service-account JSON." >&2; exit 1; } + echo "configured=true" >> "$GITHUB_OUTPUT" + + - name: Download the AAB + if: steps.key.outputs.configured == 'true' + uses: actions/download-artifact@v4 + with: + name: release-aab-${{ needs.detect.outputs.version }} + path: dist + + - name: Install Ruby + if: steps.key.outputs.configured == 'true' + run: | + set -euo pipefail + SUDO="" + if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi + $SUDO apt-get update + # ruby-dev + build-essential: several of fastlane's dependencies build + # native extensions. + $SUDO apt-get install -y ruby-full ruby-dev build-essential + ruby -v + + # Only the first release pays the full gem build; afterwards this restores. + - name: Cache bundled gems + if: steps.key.outputs.configured == 'true' + uses: actions/cache@v4 + with: + path: vendor/bundle + key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile') }} + restore-keys: | + ${{ runner.os }}-gems- + + - name: Install fastlane + if: steps.key.outputs.configured == 'true' + run: | + set -euo pipefail + gem install bundler --no-document + bundle config set --local path vendor/bundle + bundle install --jobs 4 + bundle exec fastlane --version + + - name: Upload to Play + if: steps.key.outputs.configured == 'true' + env: + SUPPLY_JSON_KEY: play-service-account.json + # supply is chatty on a TTY-less runner otherwise. + FASTLANE_SKIP_UPDATE_CHECK: '1' + FASTLANE_HIDE_CHANGELOG: '1' + run: | + set -euo pipefail + test -f "dist/app-release.aab" + bundle exec fastlane deploy \ + aab:"dist/app-release.aab" \ + track:"$PLAY_TRACK" \ + release_status:"$PLAY_RELEASE_STATUS" \ + dry_run:"$PLAY_DRY_RUN" + echo "Uploaded $VERSION (code $VERSION_CODE) to the '$PLAY_TRACK' track." + + # The workspace is reused between runs on a self-hosted runner, so the + # credential must not outlive the job. + - name: Shred the service-account key + if: always() + run: shred -u play-service-account.json 2>/dev/null || rm -f play-service-account.json diff --git a/.gitignore b/.gitignore index 3f3546f..06a7a2d 100644 --- a/.gitignore +++ b/.gitignore @@ -61,5 +61,16 @@ Thumbs.db # KSP .ksp/ +# Google Play Developer API service-account key. Reconstructed in CI from the +# PLAY_SERVICE_ACCOUNT_JSON secret and shredded afterwards — never committed. +/play-service-account.json + +# fastlane (Play uploader only — see fastlane/Fastfile) +/fastlane/report.xml +/fastlane/README.md +/vendor/bundle/ +/.bundle/ +Gemfile.lock + # Claude Code /CLAUDE.md diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..e12874e --- /dev/null +++ b/Gemfile @@ -0,0 +1,10 @@ +source "https://rubygems.org" + +# fastlane is used ONLY to upload the release bundle to Google Play +# (see fastlane/Fastfile). It is not part of the build or the signing path, so +# it never runs on a PR — only in release.yaml's `play` job. +# +# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is +# committed on purpose: this resolves an uploader's transitive deps, not the +# app's, and none of it affects the reproducible release build. +gem "fastlane", "2.237.0" diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 4426944..43a9b52 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -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/.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 diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 0000000..b0c010b --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,7 @@ +# fastlane is used in this repo ONLY as a Google Play Developer API client +# (see fastlane/Fastfile). It never builds and never signs. + +# Reconstructed in CI from the PLAY_SERVICE_ACCOUNT_JSON secret; gitignored. +json_key_file(ENV["SUPPLY_JSON_KEY"] || "play-service-account.json") + +package_name("de.jeanlucmakiola.calendula") diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..ab0256d --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,80 @@ +# Google Play publishing only. +# +# Building and signing are deliberately NOT fastlane's job: release.yaml drives +# Gradle directly so the release build stays F-Droid-reproducible (`vcsInfo`, +# `dependenciesInfo` and the AGP metadata block are disabled in +# app/build.gradle.kts and guarded by scripts/check_reproducible_release.sh). +# Interposing fastlane there would add a layer that can inject Gradle flags into +# the one build whose byte-for-byte output is verified by a third party. +# +# What fastlane IS here for is `supply` — the Play Developer API client — because +# the store listing already lives in fastlane/metadata/android, which the +# official F-Droid repo harvests from the tagged source tree. One metadata tree, +# two stores. + +default_platform(:android) + +platform :android do + desc "Upload an already-built, already-signed AAB to Play" + lane :deploy do |options| + aab = options[:aab] || "app/build/outputs/bundle/release/app-release.aab" + UI.user_error!("AAB not found at #{aab}") unless File.exist?(aab) + + supply( + aab: aab, + track: options[:track] || "internal", + release_status: options[:release_status] || "completed", + + # The APK belongs to F-Droid and the Codeberg download; Play only ever + # gets the bundle. Never let the uploader reach for the other artifact. + skip_upload_apk: true, + + # Listing text is synced deliberately via the `listing` lane, not on every + # release. Overwriting a live listing by accident triggers a Play policy + # review, which is not a quick revert. + skip_upload_metadata: true, + + # "What's New" DOES track every release. + # scripts/sync_changelog_to_fastlane.sh writes + # fastlane/metadata/android/en-US/changelogs/.txt from + # CHANGELOG.md, so Play shows exactly the text F-Droid does. + skip_upload_changelogs: false, + + # See the `listing` lane for why the committed images can't go to Play yet. + skip_upload_images: true, + skip_upload_screenshots: true, + + # Dry run: validate the edit against the API and roll it back instead of + # committing it. Used for the first end-to-end rehearsal. + validate_only: options[:dry_run].to_s == "true", + ) + end + + desc "Sync the store listing text (and, once the assets qualify, the images)" + lane :listing do |options| + # Deliberate and manual: `bundle exec fastlane listing`. Not wired into the + # release pipeline. + # + # Images stay off by default because the committed assets do not satisfy + # Play today, even though F-Droid accepts all of them: + # + # * phoneScreenshots are 1280x2856. Play caps the long edge at twice the + # short edge (2560 here), so every screenshot is rejected. + # * those PNGs are 32-bit RGBA. Play wants 24-bit PNG, no alpha. + # * icon.png is 512x512 but 24-bit RGB. Play's icon wants 32-bit PNG. + # * there is no featureGraphic.png. Play requires one, exactly 1024x500. + # + # Until that is fixed, the Play listing's graphics are managed by hand in the + # console. Pass images:true once the assets qualify. + upload_images = options[:images].to_s == "true" + + supply( + skip_upload_aab: true, + skip_upload_apk: true, + skip_upload_metadata: false, + skip_upload_changelogs: true, + skip_upload_images: !upload_images, + skip_upload_screenshots: !upload_images, + ) + end +end