5 Commits

Author SHA1 Message Date
Jean-Luc Makiola
c47653c9cb Merge pull request 'Use floret-kit's shared snack chip' (#6) from feat/shared-snack-chip into main
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Renovate / renovate (push) Failing after 45s
2026-08-02 20:46:11 +02:00
e639e250b7 Use floret-kit's shared snack chip
The undo chip moves into the kit so Calendula's reschedule confirmation is
the same component. Re-pins the kit onto the branch that carries it.
2026-08-02 20:45:39 +02:00
Jean-Luc Makiola
58a50512bf docs: settle the storage and sync direction (#5)
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/5
2026-08-02 17:58:13 +02:00
Jean-Luc Makiola
1add1fcadb chore: re-pin floret-kit and pass allDay at the date call sites (#4)
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/4
2026-08-02 17:57:58 +02:00
Jean-Luc Makiola
217d5d7afd Move the canonical forge to Codeberg, and add Renovate + Weblate (#2)
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Renovate / renovate (push) Failing after 43s
Makes Codeberg canonical for git, issues, PRs, tags and releases. The self-hosted Gitea instance stays build infrastructure: signing key, F-Droid publishing, release pipeline. Ports the setup Calendula already runs on, adapted where Agendula genuinely differs.

**This PR is its own test.** It is the first PR opened on Codeberg, so a green `CI` check proves the new runner works *and* that the submodule resolves from its new home.

### 1 · Workflows split by directory

Forgejo's lookup is first-match-wins across `.forgejo/` → `.gitea/` → `.github/`, and Gitea cannot see `.forgejo/` at all. So each forge sees exactly one set, with no duplicated files and no expression to keep in sync:

| Directory | Runs on | Contains | Secrets |
| --- | --- | --- | --- |
| `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** |
| `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | all of them |

The line is drawn at **secrets, not CI-vs-release** — that is what makes fork PRs safe. Renovate deliberately does *not* move despite opening PRs here; it keeps running where its token already lives and merely talks to Codeberg's API.

CI also gains three fixes: an explicit `SKIP_RE` skip-list that names the build-relevant files in the log, base-ref normalisation, and a fully-qualified `android-actions/setup-android` — Codeberg resolves bare `uses:` refs against `data.forgejo.org`, which does not carry that action.

### 2 · Three release-pipeline safety changes

- `detect` and the Renovate job get an explicit `repository_owner` guard. The directory split only holds while `.forgejo/` is non-empty; empty it and Codeberg would fall back to `.gitea/` and start running these on the contributor-facing runner, without secrets.
- `detect` now reads tags from **Codeberg**, not from the Gitea instance it runs on. Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by the next sync until the Codeberg tag push propagates back — asking Gitea inside that window reports "no tag" for an already-shipped release and would cut it twice. It also now fails on any status other than 200/404 rather than reading a transient error as "no tag": a failed job is recoverable, a duplicate release is not.
- **The Codeberg publish step pushes the tag itself** instead of waiting for it to arrive by mirror. That wait was correct while Gitea mirrored *to* Codeberg; under Codeberg-canonical the mirror runs the other way and it would never resolve. Attaching the release to an already-pushed ref (no `target_commitish`) is what avoids the empty-bodied 500s, and the create call retries with backoff because Codeberg 500s on a tag it has only just received. The step stays **fail-loud**, not `continue-on-error` — it reported green through 0.2.1–0.3.2 while never once publishing, and that must not be possible again.

### 3 · Renovate

`renovate.json5` plus a Gitea-side job targeting Codeberg's API. `managerFilePatterns` covers **both** workflow directories, so the pinned Renovate image tag and the action versions in either file keep getting bumped.

Needs two new Gitea secrets: `RENOVATE_TOKEN` (Codeberg bot, repo read/write + PR scope) and `GITHUB_COM_TOKEN` (read-only github.com PAT, for changelog lookups).

### 4 · Weblate

A parity check (`scripts/check_translations.py`) runs on every PR without a path filter, so the required `Translations` status is always reported. Partial translations are expected, so `MissingTranslation` and `MissingQuantity` become informational — `ExtraTranslation` stays fatal. Agendula had no `lint` block at all, so the first locale to land would otherwise have failed the build.

**Settings → App language** now opens a picker carrying a "Help translate" header. That is why it drops floret-kit's `LanguagePickerRow` for a local row: the shared recipe has no `header` slot, and the framing is app-specific rather than a family primitive.

### 5 · Links repointed

In-app Source / License / report-issue URLs, F-Droid metadata, README (now with a Codeberg CI badge), and the docs.

`floret-kit` follows suit — `.gitmodules` points at `codeberg.org/jlmakiola/floret-kit`, so a clone no longer needs to reach the personal Gitea instance to resolve it. The Gitea copy is **kept**: every existing tag records the old submodule URL, so rebuilds of past releases still resolve.

### 6 · Housekeeping

Drops `release-notes.md` — a release-pipeline scratch file that got committed — and gitignores the five others the release job writes into the workspace.

### Not in this PR

The Codeberg → Gitea push mirror, the Weblate component, and the Codeberg bot account (all browser-side). Until the mirror is flipped, merging this does **not** reach the Gitea runner.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/2
2026-08-01 16:34:20 +02:00
30 changed files with 1128 additions and 175 deletions

View File

@@ -0,0 +1,24 @@
# Kept enabled so anything that doesn't fit the four templates still has a way
# in.
blank_issues_enabled: true
contact_links:
- name: Translate Agendula
url: https://weblate.dev.jeanlucmakiola.de/engage/agendula/
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/agendula/src/branch/main/CONTRIBUTING.md
about: >-
Before opening a pull request: how to build (there's a submodule), where
code goes, and the one architectural rule a change is reviewed against.
- name: Sync sources and scope
url: https://codeberg.org/jlmakiola/agendula/src/branch/main/README.md
about: >-
Agendula is a front-end over the OpenTasks provider, so it works with
DAVx5, SmoothSync, DecSync and friends. Google Tasks and Microsoft To Do
are out of scope by design — check here before requesting a backend.

View File

@@ -0,0 +1,42 @@
<!--
Thanks for contributing to Agendula!
Please skim CONTRIBUTING.md if you haven't:
https://codeberg.org/jlmakiola/agendula/src/branch/main/CONTRIBUTING.md
Two things it's easy to get wrong:
• The one architectural rule — provider column names, `TaskContract`,
`ContentResolver` and the authority string never leak above `data/tasks/`.
• Don't bump `versionName` / `versionCode`. That bump reaching `main` is what
cuts a release, so it belongs only in a release PR.
-->
### What this changes
### Why
<!-- Closes #123 — link the issue this implements or fixes. -->
### How it was tested
<!--
Which of these ran green, and anything you exercised by hand. On-device notes
are especially useful for UI changes, and for anything touching the provider
read/write paths (OpenTasks / tasks.org installed).
./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug
python3 scripts/check_translations.py
-->
### Checklist
- [ ] `./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug` passes locally
- [ ] New domain logic comes with JVM unit tests under `app/src/test/`
- [ ] Provider details stay inside `data/tasks/`
- [ ] 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 `versionName` / `versionCode` bump
- [ ] No planning or design documents committed

View File

@@ -37,14 +37,29 @@ jobs:
- name: Reproducible-release invariant - name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh run: bash scripts/check_reproducible_release.sh
# Decide whether anything that affects the app build changed. Docs, # Decide whether anything that affects the app build changed. Docs, store
# F-Droid metadata and the licence don't, so those PRs skip the SDK + # metadata, licence texts and forge housekeeping don't, so those PRs skip
# Gradle work below but still report a green `ci`. # the SDK + Gradle work below but still report a green `ci`.
- name: Classify change scope - name: Classify change scope
id: 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/|^design/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^LICENSE$)'
run: | run: |
set -e set -e
BASE="${{ github.base_ref }}" 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 # 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 # the PR branch forked several commits back; a shallow tip has no merge
# base with a divergent branch and `git diff base...HEAD` aborts. # base with a divergent branch and `git diff base...HEAD` aborts.
@@ -58,11 +73,15 @@ jobs:
fi fi
CHANGED=$(git diff --name-only "$MB" HEAD) CHANGED=$(git diff --name-only "$MB" HEAD)
echo "Changed files:"; echo "$CHANGED" 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" echo "code=true" >> "$GITHUB_OUTPUT"
else else
echo "code=false" >> "$GITHUB_OUTPUT"
echo "Docs/metadata-only change — skipping the Android build." echo "Docs/metadata-only change — skipping the Android build."
echo "code=false" >> "$GITHUB_OUTPUT"
fi fi
- name: Setup Java - name: Setup Java
@@ -72,9 +91,14 @@ jobs:
distribution: 'zulu' distribution: 'zulu'
java-version: '17' java-version: '17'
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
# setup-java and cache all exist there, but android-actions/setup-android
# does not, and the job dies with "repository not found". Gitea's instance
# defaults to GitHub, which is why this never surfaced before the split.
- name: Setup Android SDK - name: Setup Android SDK
if: steps.scope.outputs.code == 'true' if: steps.scope.outputs.code == 'true'
uses: android-actions/setup-android@v3 uses: https://github.com/android-actions/setup-android@v3
with: with:
# Default ("tools platform-tools") drags in the Android Emulator # Default ("tools platform-tools") drags in the Android Emulator
# (~300 MB) which the build never uses. # (~300 MB) which the build never uses.

View File

@@ -0,0 +1,39 @@
name: Translations
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
# only touch values-*/strings.xml) get quick feedback without the full Android
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
#
# Runs on every PR (no path filter) so the required "Translations / check"
# status is always reported — like the `ci` job. A path-filtered workflow is
# skipped on unrelated PRs and never posts its status, which leaves that
# required check pending forever and blocks the merge of any code-only PR into a
# release/* branch. The check itself is cheap and simply passes when the
# committed translations are consistent, so always running it costs nothing.
on:
pull_request:
concurrency:
group: translations-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Ensure python3
run: |
if ! command -v python3 >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
apt-get update && apt-get install -y python3
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache python3
fi
fi
python3 --version
- name: Check translation parity
run: python3 scripts/check_translations.py

View File

@@ -3,12 +3,18 @@ name: Release — F-Droid repo + Gitea/Codeberg release
# A release is cut by merging a release branch into main with a bumped # 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, # versionName (see docs/RELEASING.md). This workflow reads that versionName and,
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes # if no matching tag exists yet, runs tests, builds + signs the APK, publishes
# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and mirrors # it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and publishes
# that release to Codeberg with the signed APK + a SHA-256 checksum as a # the release on Codeberg with the signed APK + a SHA-256 checksum as a
# direct-download channel — the tag is an output of the pipeline, not its # direct-download channel — the tag is an output of the pipeline, not its
# trigger. Ordinary merges (no version bump) fall through `detect` and do # trigger. Ordinary merges (no version bump) fall through `detect` and do
# nothing. # nothing.
# #
# This file lives in .gitea/workflows on purpose: Codeberg is canonical for git,
# issues, PRs and releases, but every secret (app key, F-Droid repo key, Hetzner
# credentials) lives on the self-hosted Gitea instance, and this is the only
# directory Codeberg cannot see. Contributor-triggerable work lives in
# .forgejo/workflows and references no secret. See docs/RELEASING.md.
#
# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery # 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, # 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 # without building an APK or creating a release. Used for key rotation / repo
@@ -27,6 +33,14 @@ jobs:
# whether this push actually cuts a new release (no tag for it yet). Keeps the # whether this push actually cuts a new release (no tag for it yet). Keeps the
# heavy job from running on every merge to main. # heavy job from running on every merge to main.
detect: detect:
# Gitea only. The workflow directory split already keeps this file invisible
# to Codeberg — Forgejo's lookup is first-match-wins, and .forgejo/workflows
# exists — but that only holds while .forgejo/ is non-empty. Move the last
# file out of it and Codeberg would fall back to .gitea/workflows and start
# running the release pipeline on the contributor-facing runner, with no
# secrets. repository_owner differs between the two forges regardless of
# URL, proxy or instance rename, so this closes it permanently.
if: github.repository_owner == 'makiolaj'
runs-on: docker runs-on: docker
outputs: outputs:
is_release: ${{ steps.v.outputs.is_release }} is_release: ${{ steps.v.outputs.is_release }}
@@ -42,8 +56,16 @@ jobs:
- name: Resolve version and whether it is a new release - name: Resolve version and whether it is a new release
id: v id: v
env: env:
TOKEN: ${{ secrets.GITHUB_TOKEN }} # Tags are read from Codeberg, which is canonical — deliberately NOT
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} # from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
# source does not have. A tag minted here on Gitea is therefore wiped
# by the next sync (Codeberg does not have it yet) and only reappears
# once the tag push at the end of this workflow propagates back.
# Asking Gitea inside that window would report "no tag" for a release
# that already shipped, and cut it a second time.
# Public repo, so this read needs no token.
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
run: | run: |
set -e set -e
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts) VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
@@ -65,15 +87,28 @@ jobs:
fi fi
# A tag for this version already existing means the release shipped on # A tag for this version already existing means the release shipped on
# an earlier push; do nothing. Absent => this merge cuts the release. # an earlier push; do nothing. Absent => this merge cuts the release.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \ #
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION") # Anything other than a clean 200/404 is treated as fatal rather than
if [ "$STATUS" = "200" ]; then # as "no tag". A Codeberg outage or a network blip would otherwise
echo "Tag v$VERSION already exists — nothing to release." # read as absent and re-cut a release that has already shipped —
echo "is_release=false" >> "$GITHUB_OUTPUT" # republishing to F-Droid. Failing here is recoverable; a duplicate
else # release is not.
echo "No tag for v$VERSION yet — cutting the release." STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
echo "is_release=true" >> "$GITHUB_OUTPUT" case "$STATUS" in
fi 200)
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
;;
404)
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
echo "is_release=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
exit 1
;;
esac
# Releases: build + sign + publish, then mint the tag and Gitea release. # Releases: build + sign + publish, then mint the tag and Gitea release.
# Also runs on manual dispatch, where it skips the build and just re-signs and # Also runs on manual dispatch, where it skips the build and just re-signs and
@@ -360,14 +395,15 @@ jobs:
-F "attachment=@/tmp/$ASSET" \ -F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n" "$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
# Mirror the release to the Codeberg mirror as a direct-download channel # Publish the release on Codeberg, which is canonical for tags and
# for users who don't want F-Droid. Gitea already push-mirrors branches + # releases (see docs/RELEASING.md). Codeberg push-mirrors branches + tags
# tags to Codeberg, but releases aren't git objects so they don't sync # to Gitea, but releases aren't git objects and don't sync in either
# we create the release there over the API and attach the signed APK plus # direction — so this step pushes the tag straight to Codeberg and creates
# a SHA-256 checksum. The APK is identical to the F-Droid one (same app # the release there over the API, attaching the signed APK plus a SHA-256
# key), so this adds no trust surface. Best-effort: a Codeberg outage # checksum as the direct-download channel for users who don't want
# (it 504s under load) must never fail an already-published F-Droid # F-Droid. The APK is identical to the F-Droid one (same app key), so this
# release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset. # adds no trust surface. Needs the CODEBERG_RELEASE_TOKEN secret; skips
# cleanly if unset.
- name: Publish release to Codeberg - name: Publish release to Codeberg
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2, # NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
@@ -377,6 +413,7 @@ jobs:
env: env:
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }} TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
SHA: ${{ github.sha }}
run: | run: |
set -e set -e
if [ -z "${TOKEN:-}" ]; then if [ -z "${TOKEN:-}" ]; then
@@ -401,34 +438,21 @@ jobs:
sed -i -e '/./,$!d' release-notes.md sed -i -e '/./,$!d' release-notes.md
fi fi
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md [ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
# Never mint the tag here. Gitea's push mirror owns getting it to # Push the tag to Codeberg ourselves. Under Codeberg-canonical the
# Codeberg; this step's only job is to attach a release to a tag that # mirror runs Codeberg -> Gitea, so waiting for a tag to arrive here
# has already landed. That split matters because every way of creating # from Gitea (what 0.3.2 did) would wait forever. The tag this
# a tag from here — git push, or a release POST carrying # pipeline minted on Gitea is in fact *deleted* by the next mirror
# target_commitish for a tag Codeberg lacks — is a ref WRITE, and ref # sync until Codeberg has it — so pushing it here is what makes it
# writes are what fail on this repo ("cannot lock references" on push, # durable on both forges.
# an empty-bodied 500 on the API). Attaching to a tag that is already
# present needs no ref write and succeeds.
# #
# So: wait for the mirror, verify, then attach. If the tag never shows # Pushing the ref first and attaching with NO target_commitish is
# up, fail — do NOT fall back to creating it, which is what produced # deliberate: a release POST carrying a target_commitish for a commit
# the silent breakage across 0.2.1 through 0.3.2. # or tag Codeberg hasn't received yet is what produced the
TAG_OK="" # empty-bodied 500s. Attaching to a ref that already exists doesn't
for i in $(seq 1 30); do # need the API to write one.
if [ "$(curl -s -o /dev/null -w '%{http_code}' \ git tag -f "$TAG" "$SHA"
-H "Authorization: token $TOKEN" "$API/tags/$TAG")" = "200" ]; then git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/agendula.git" \
TAG_OK=1; echo "Codeberg has $TAG (after ~$((i*10))s)"; break "refs/tags/$TAG"
fi
sleep 10
done
if [ -z "$TAG_OK" ]; then
echo "Codeberg never received $TAG from the push mirror (waited 300s)." >&2
echo "Not creating it here: ref writes to this repo fail, so that" >&2
echo "would 500. Check the mirror, then re-run once the tag is there." >&2
exit 1
fi
# No target_commitish: the tag exists, so the API must attach to it
# rather than resolve a commit and mint one.
python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json
import json, sys import json, sys
tag, pre = sys.argv[1:3] tag, pre = sys.argv[1:3]
@@ -441,20 +465,31 @@ jobs:
"prerelease": pre == "true", "prerelease": pre == "true",
})) }))
PY PY
# Upsert (re-run safe): a release already attached to this tag is # Create (or update) the release. Codeberg 500s on a POST/GET against a
# PATCHed in place, so re-running never disturbs a published release. # tag it has only just received — the release request outruns the
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty') # indexing of the ref we pushed a moment ago — so a single attempt can
if [ -n "$ID" ]; then # fail even though the very same call succeeds seconds later. Retry
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \ # with backoff, and PATCH in place if a release already exists (re-run
# safe, so re-running never disturbs a published release). A 5xx body
# still exits curl 0, so the loop, not `set -e`, controls the flow.
ID=""
for attempt in 1 2 3 4 5 6; do
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
if [ -n "$EXIST" ]; then
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases/$EXIST"
ID="$EXIST"; break
fi
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases/$ID" -d @cb-payload.json "$API/releases")
else echo "release POST attempt $attempt HTTP $CODE"
curl -s -o cb-response.json -w "release POST HTTP %{http_code}\n" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases"
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true) ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
fi [ -n "$ID" ] && break
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id." >&2; exit 1; fi sleep $((attempt * 10))
done
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
# Attach APK + checksum, replacing any prior asset of the same name. # Attach APK + checksum, replacing any prior asset of the same name.
for A in "$ASSET_APK" "$ASSET_SUM"; do for A in "$ASSET_APK" "$ASSET_SUM"; do

View File

@@ -0,0 +1,61 @@
name: Renovate
on:
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
schedule:
- cron: '0 5 * * 1'
# Manual run for an on-demand sweep from the Actions tab.
workflow_dispatch:
# Never let two Renovate runs touch the repo at once.
concurrency:
group: renovate
cancel-in-progress: false
jobs:
renovate:
# Gitea only — same guard, and the same reason, as release.yaml's `detect`:
# this file is invisible to Codeberg only while .forgejo/ is non-empty, and
# a repo-write token must never run on the contributor-facing runner.
if: github.repository_owner == 'makiolaj'
runs-on: docker
# Run the Renovate image *as* the job container and invoke the `renovate`
# binary directly. The renovatebot/github-action wrapper is a thin Node
# action that shells out to `docker run …` — it needs a Docker CLI + socket
# inside the job, which the Gitea runner's plain node container has not, so
# it died on "Unable to locate executable file: docker". Running the image
# directly drops the docker-in-docker requirement entirely.
# Full tag pinned; Renovate's github-actions manager keeps it bumped.
container:
image: ghcr.io/renovatebot/renovate:43.232.0
steps:
- name: Run Renovate
run: renovate
env:
# Renovate targets Codeberg (canonical) while still RUNNING on the
# Gitea runner. Moving the job to Codeberg would put a repo-write
# token on the contributor-facing runner, which is exactly what the
# .forgejo/ vs .gitea/ split exists to prevent — so the token stays
# where the other secrets live and only the API calls cross over.
#
# Platform is `forgejo`, not `gitea`: Codeberg runs Forgejo, and the
# pinned image ships a distinct forgejo platform module.
RENOVATE_PLATFORM: forgejo
RENOVATE_ENDPOINT: https://codeberg.org/api/v1
# Codeberg bot-account token (Gitea secret). Needs repo read/write +
# PR scope on jlmakiola/agendula.
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
# Scope to this repo only — no org-wide autodiscovery.
RENOVATE_AUTODISCOVER: 'false'
RENOVATE_REPOSITORIES: '["jlmakiola/agendula"]'
# Commits/PRs authored as the bot, not a real maintainer. This address
# must be a verified email on the Codeberg bot account, otherwise the
# commits show up unattributed there.
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>'
# Read-only github.com PAT (no scopes needed). Nearly every dependency
# is *released* on GitHub, and without this, changelog/release-note
# lookups hit the 60/h anonymous rate limit and PRs arrive with an
# empty "Release Notes" section.
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.GITHUB_COM_TOKEN }}
LOG_LEVEL: info

10
.gitignore vendored
View File

@@ -53,5 +53,15 @@ Thumbs.db
# F-Droid local artifacts (the pipeline generates them in CI) # F-Droid local artifacts (the pipeline generates them in CI)
/fdroid/ /fdroid/
# Release-pipeline scratch files. release.yaml writes these into the workspace
# while cutting a release; a self-hosted runner reuses that workspace, so they
# must never end up committed (release-notes.md did, through 0.3.2).
/release-notes.md
/payload.json
/existing.json
/response.json
/cb-payload.json
/cb-response.json
# KSP # KSP
.ksp/ .ksp/

2
.gitmodules vendored
View File

@@ -1,3 +1,3 @@
[submodule "floret-kit"] [submodule "floret-kit"]
path = floret-kit path = floret-kit
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git url = https://codeberg.org/jlmakiola/floret-kit.git

View File

@@ -7,6 +7,18 @@ All notable changes to this project are documented here. The format follows
## [Unreleased] ## [Unreleased]
### Added
- Agendula can now be translated. Pick or request a language on Weblate and
translate in the browser — the link sits at the top of the language picker in
**Settings → App language**. Partial translations are fine; anything
untranslated falls back to English.
### Changed
- Agendula's home is now **Codeberg** (`jlmakiola/agendula`) — that's where the
source, issues, pull requests and releases live. The Source and License links
in Settings, the issue-reporting link and the F-Droid metadata all point there
now. The self-hosted Gitea instance stays as build infrastructure.
## [0.3.2] - 2026-07-20 ## [0.3.2] - 2026-07-20
### Fixed ### Fixed

View File

@@ -33,10 +33,33 @@ would expose provider details to a ViewModel or the UI, it's in the wrong layer.
./gradlew lintDebug # Android lint (CI runs this on every PR) ./gradlew lintDebug # Android lint (CI runs this on every PR)
``` ```
CI (`.gitea/workflows/ci.yaml`) runs a reproducible-release invariant check, CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check,
then lint → unit tests → debug build on every pull request, so run these locally then lint → unit tests → debug build on every pull request, so run these locally
before opening a PR. Keep CI green. before opening a PR. Keep CI green.
## Translations
**Never edit a `values-*/strings.xml` file in a pull request.** 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 Agendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
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, and it's what the `Translations` check runs on every PR.
A new language also needs one `<locale>` line in
`app/src/main/res/xml/locales_config.xml` — that file is the single source of
truth for both the in-app picker and the Android 13+ per-app language setting.
## Where to put code ## Where to put code
| Layer | Lives in | Rule of thumb | | Layer | Lives in | Rule of thumb |

View File

@@ -6,6 +6,7 @@
Reads, writes, and reminds — on top of an existing tasks provider, with no own Reads, writes, and reminds — on top of an existing tasks provider, with no own
sync stack.</p> sync stack.</p>
<a href="https://codeberg.org/jlmakiola/agendula/actions"><img src="https://codeberg.org/jlmakiola/agendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+"> <img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose"> <img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive"> <img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
@@ -13,7 +14,7 @@ sync stack.</p>
</div> </div>
Agendula is the task-list sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula). Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula is Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula is
a pure front-end over the **OpenTasks `TaskContract` provider** — the store that a pure front-end over the **OpenTasks `TaskContract` provider** — the store that
DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own
@@ -40,6 +41,18 @@ adapter — because it builds on the provider, not on any one sync app. Google
Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean
owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are the lane. owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are the lane.
## Translations
Agendula ships in English so far, and would like not to. Translations are
managed on a self-hosted **Weblate**, and partial ones are fine — an
untranslated string simply falls back to English.
**→ [Help translate Agendula](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
No coding needed: register on the Weblate server, pick (or request) a language,
and translate the strings in your browser. You can also reach this link in the
app from the top of **Settings → App language**.
## License ## License
MIT — see [LICENSE](LICENSE). MIT — see [LICENSE](LICENSE).

View File

@@ -111,6 +111,18 @@ android {
} }
} }
lint {
// Community translations are expected to be partial — a missing string
// falls back to the English base at runtime — so don't fail the build on
// it. Likewise a translated <plurals> may not fill every CLDR quantity
// form its locale defines (e.g. Arabic needs "zero"); the missing form
// falls back to "other" at runtime, so MissingQuantity is informational
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
// check_translations.py guards the same invariants with clearer,
// translator-facing messages.
informational += listOf("MissingTranslation", "MissingQuantity")
}
testOptions { testOptions {
unitTests { unitTests {
all { it.useJUnitPlatform() } all { it.useJUnitPlatform() }

View File

@@ -501,13 +501,17 @@ private fun taskWhenLines(task: Task): Pair<String, String?>? {
val due = task.due val due = task.due
return when { return when {
start != null && due != null -> { start != null && due != null -> {
val sameDay = start.formatDate() == due.formatDate() val allDay = task.isAllDay
val primary = if (sameDay) due.formatDate() else "${start.formatDate()} ${due.formatDate()}" val sameDay = start.formatDate(allDay) == due.formatDate(allDay)
val secondary = if (task.isAllDay) null else "${start.formatTime()} ${due.formatTime()}" val primary =
if (sameDay) due.formatDate(allDay)
else "${start.formatDate(allDay)} ${due.formatDate(allDay)}"
val secondary = if (allDay) null else "${start.formatTime()} ${due.formatTime()}"
primary to secondary primary to secondary
} }
due != null -> due.formatDate() to if (task.isAllDay) null else due.formatTime() due != null -> due.formatDate(task.isAllDay) to if (task.isAllDay) null else due.formatTime()
start != null -> start.formatDate() to if (task.isAllDay) null else start.formatTime() start != null ->
start.formatDate(task.isAllDay) to if (task.isAllDay) null else start.formatTime()
else -> null else -> null
} }
} }

View File

@@ -653,7 +653,7 @@ private fun ScheduleRow(
) )
} else { } else {
Text( Text(
text = value.formatDate(), text = value.formatDate(allDay),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = valueColor, color = valueColor,
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp), modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
@@ -865,7 +865,7 @@ private fun ParentPickerSheet(
GroupedRow( GroupedRow(
title = task.title.ifBlank { stringResource(R.string.task_untitled) }, title = task.title.ifBlank { stringResource(R.string.task_untitled) },
position = positionOf(index, section.tasks.size), position = positionOf(index, section.tasks.size),
summary = task.due?.formatDate(), summary = task.due?.formatDate(task.isAllDay),
selected = task.taskId == selectedId, selected = task.taskId == selectedId,
minHeight = 56.dp, minHeight = 56.dp,
onClick = { choose(task.taskId) }, onClick = { choose(task.taskId) },

View File

@@ -43,6 +43,7 @@ import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.rounded.AccountTree import androidx.compose.material.icons.rounded.AccountTree
import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Circle
@@ -84,7 +85,6 @@ import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.floret.components.AboutCard import de.jeanlucmakiola.floret.components.AboutCard
import de.jeanlucmakiola.floret.components.AboutLink import de.jeanlucmakiola.floret.components.AboutLink
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.LanguagePickerRow
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
@@ -92,6 +92,7 @@ import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.locale.AppLanguage
import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
@@ -211,19 +212,70 @@ private fun SettingsHub(
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) }, leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
onClick = { onOpenSection(SettingsSection.Reminders) }, onClick = { onOpenSection(SettingsSection.Reminders) },
) )
LanguagePickerRow( LanguageRow(position = Position.Middle)
position = Position.Middle,
title = stringResource(R.string.settings_language),
autoLabel = stringResource(R.string.settings_language_auto),
localesConfig = R.xml.locales_config,
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
)
ReportProblemRow(position = Position.Bottom) ReportProblemRow(position = Position.Bottom)
AppVersionText() AppVersionText()
} }
} }
/**
* The app-language row. Deliberately not floret-kit's `LanguagePickerRow`: the
* picker it opens carries a "Help translate" header, and inviting contributions
* right where a user goes looking for their language is app-specific framing,
* not a family primitive. Everything else matches that recipe.
*/
@Composable
private fun LanguageRow(position: Position) {
val context = LocalContext.current
// Setting a locale recreates the activity; mirror the choice locally so the
// row updates instantly even before the recreation lands.
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
var showDialog by remember { mutableStateOf(false) }
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, R.xml.locales_config) }
GroupedRow(
title = stringResource(R.string.settings_language),
summary = languageLabel(current),
position = position,
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
onClick = { showDialog = true },
)
if (showDialog) {
OptionPicker(
title = stringResource(R.string.settings_language),
predictiveBack = true,
options = options,
selected = current,
label = { languageLabel(it) },
onSelect = {
current = it
AppLanguage.apply(it)
},
onDismiss = { showDialog = false },
// Invite contributions right where users pick their language.
header = {
val translateUrl = stringResource(R.string.about_translate_url)
GroupedRow(
title = stringResource(R.string.settings_translate),
summary = stringResource(R.string.settings_translate_hint),
position = Position.Alone,
leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) },
onClick = { openUrl(context, translateUrl) },
)
Spacer(Modifier.height(16.dp))
},
)
}
}
@Composable
private fun languageLabel(tag: String?): String =
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
/** Opens the project's issue tracker; no data leaves the device until submitted. */ /** Opens the project's issue tracker; no data leaves the device until submitted. */
@Composable @Composable
private fun ReportProblemRow(position: Position) { private fun ReportProblemRow(position: Position) {

View File

@@ -98,6 +98,9 @@ import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSections import de.jeanlucmakiola.agendula.domain.TaskSections
import de.jeanlucmakiola.agendula.ui.common.priorityAccent import de.jeanlucmakiola.agendula.ui.common.priorityAccent
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SnackChip
import de.jeanlucmakiola.floret.components.SnackChipHeight
import de.jeanlucmakiola.floret.components.SnackChipMargin
import de.jeanlucmakiola.floret.time.formatDateTimeCompact import de.jeanlucmakiola.floret.time.formatDateTimeCompact
import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
@@ -189,13 +192,18 @@ fun TaskListScreen(
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.padding(start = 16.dp, bottom = inner.calculateBottomPadding() + 16.dp) .padding(
.height(56.dp), start = SnackChipMargin,
bottom = inner.calculateBottomPadding() + SnackChipMargin,
)
.height(SnackChipHeight),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
UndoChip( SnackChip(
visible = undoTarget != null, visible = undoTarget != null,
onUndo = { message = stringResource(R.string.task_deleted),
actionLabel = stringResource(R.string.undo),
onAction = {
undoTarget?.let { viewModel.undoDelete(it.taskId) } undoTarget?.let { viewModel.undoDelete(it.taskId) }
undoTarget = null undoTarget = null
}, },
@@ -205,47 +213,6 @@ fun TaskListScreen(
} }
} }
/**
* A compact floating "snackchip" for an undoable delete — a rounded pill (not a
* full-width snackbar) sized to its content, sliding up from the bottom centre.
*/
@Composable
private fun UndoChip(visible: Boolean, onUndo: () -> Unit, modifier: Modifier = Modifier) {
AnimatedVisibility(
visible = visible,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
modifier = modifier,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurface,
shape = RoundedCornerShape(50),
shadowElevation = 6.dp,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 20.dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.task_deleted),
style = MaterialTheme.typography.bodyMedium,
)
TextButton(
onClick = onUndo,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 6.dp),
) {
Text(
text = stringResource(R.string.undo),
style = MaterialTheme.typography.labelLarge,
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
private fun TaskListBody( private fun TaskListBody(

View File

@@ -190,13 +190,16 @@
<string name="settings_about_logo_desc">Agendula app icon</string> <string name="settings_about_logo_desc">Agendula app icon</string>
<string name="settings_language">App language</string> <string name="settings_language">App language</string>
<string name="settings_language_auto">System default</string> <string name="settings_language_auto">System default</string>
<string name="settings_translate">Help translate</string>
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
<string name="settings_report_problem">Report a problem</string> <string name="settings_report_problem">Report a problem</string>
<string name="settings_report_problem_hint">Open the issue tracker</string> <string name="settings_report_problem_hint">Open the issue tracker</string>
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula</string> <string name="about_source_url" translatable="false">https://codeberg.org/jlmakiola/agendula</string>
<string name="crash_report_issue_title">Crash report</string> <string name="crash_report_issue_title">Crash report</string>
<string name="report_issue_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/issues/new</string> <string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/agendula/issues/new</string>
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/src/branch/main/LICENSE</string> <string name="about_license_url" translatable="false">https://codeberg.org/jlmakiola/agendula/src/branch/main/LICENSE</string>
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string> <string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
<string name="about_translate_url" translatable="false">https://weblate.dev.jeanlucmakiola.de/engage/agendula/</string>
<string name="settings_theme">Theme</string> <string name="settings_theme">Theme</string>
<string name="settings_theme_system">Follow system</string> <string name="settings_theme_system">Follow system</string>
<string name="settings_theme_light">Light</string> <string name="settings_theme_light">Light</string>

View File

@@ -12,7 +12,7 @@ Agendula is a Material 3 Expressive **front-end** over the OpenTasks
`TaskContract` provider — it reads, writes, and reminds on top of a tasks store `TaskContract` provider — it reads, writes, and reminds on top of a tasks store
that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over
CalDAV. **Agendula owns no database and no sync stack.** It is the task-list CalDAV. **Agendula owns no database and no sync stack.** It is the task-list
sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula), sibling to [Calendula](https://codeberg.org/jlmakiola/calendula),
which does the same thing for `CalendarContract`. which does the same thing for `CalendarContract`.
The whole design hangs off one rule: The whole design hangs off one rule:
@@ -244,8 +244,8 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
| Other | DataStore, kotlinx-datetime, kotlinx-coroutines | | Other | DataStore, kotlinx-datetime, kotlinx-coroutines |
| Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam | | Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam |
| Versioning | committed `versionName` is the source of truth; a bump reaching `main` triggers the release and the pipeline mints the `vX.Y.Z` tag. `versionCode = MAJOR*10000 + MINOR*100 + PATCH`. See [`RELEASING.md`](RELEASING.md). | | Versioning | committed `versionName` is the source of truth; a bump reaching `main` triggers the release and the pipeline mints the `vX.Y.Z` tag. `versionCode = MAJOR*10000 + MINOR*100 + PATCH`. See [`RELEASING.md`](RELEASING.md). |
| CI | Gitea workflows (`.gitea/workflows/ci.yaml`, `release.yaml`) | | CI | Split by forge: `.forgejo/workflows/ci.yaml` on Codeberg (canonical, no secrets), `.gitea/workflows/release.yaml` on Gitea (all secrets). See [`RELEASING.md`](RELEASING.md). |
| Distribution | F-Droid (`fdroid-metadata/`) | | Distribution | F-Droid (`fdroid-metadata/`) + Codeberg release APKs |
--- ---

View File

@@ -3,7 +3,7 @@
Agendula is a Material 3 Expressive **task** app for Android: a pure front-end over Agendula is a Material 3 Expressive **task** app for Android: a pure front-end over
the OpenTasks `TaskContract` provider (synced by DAVx5 / SmoothSync / DecSync the OpenTasks `TaskContract` provider (synced by DAVx5 / SmoothSync / DecSync
over CalDAV), with no own database or sync stack. Sibling to over CalDAV), with no own database or sync stack. Sibling to
[Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula). See the [Calendula](https://codeberg.org/jlmakiola/calendula). See the
top-level [`../README.md`](../README.md) for the project pitch. top-level [`../README.md`](../README.md) for the project pitch.
## Index ## Index

View File

@@ -1,15 +1,18 @@
# Agendula — releasing # Agendula — releasing
Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner) with Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner) and a
a human-readable **Gitea release** per version. Both are produced automatically **Codeberg release** per version carrying the signed APK as a direct download.
by `.gitea/workflows/release.yaml` when a **bumped `versionName` reaches `main`** Both are produced automatically by `.gitea/workflows/release.yaml` when a
— the pipeline builds and publishes that version, then creates the matching **bumped `versionName` reaches `main`** — the pipeline builds and publishes that
`vX.Y.Z` tag and Gitea release itself. There are no APK assets on the Gitea version, then creates the matching `vX.Y.Z` tag and the releases itself. The
release: distribution lives in the F-Droid repo; the release is the changelog of parallel **Gitea release** is the changelog of record on the build instance and
record. carries no APK assets.
While Agendula is pre-1.0 (`versionName` starts with `0.`), every Gitea release Codeberg is the canonical forge; Gitea is build infrastructure. See
is flagged as a **pre-release**. This happens automatically and graduates to a [Two forges, one repo](#two-forges-one-repo) for how the two are wired.
While Agendula is pre-1.0 (`versionName` starts with `0.`), every release is
flagged as a **pre-release**. This happens automatically and graduates to a
stable release at `1.0.0` — no manual toggling. stable release at `1.0.0` — no manual toggling.
--- ---
@@ -80,35 +83,49 @@ re-running the workflow safely retries).
CI and release are split so a change is built once on its PR and only does CI and release are split so a change is built once on its PR and only does
release work when a merge actually cuts a release: release work when a merge actually cuts a release:
- **`ci.yaml`** (on `pull_request`) — the reproducible-release invariant guard - **`ci.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**) — the
reproducible-release invariant guard
(`scripts/check_reproducible_release.sh`), then lint + unit tests + a debug (`scripts/check_reproducible_release.sh`), then lint + unit tests + a debug
assemble and a Trivy scan, once per PR. Docs/metadata-only PRs skip the Android assemble and a Trivy scan, once per PR. Docs/metadata-only PRs skip the Android
build but still report a green `CI` check. build but still report a green `CI` check.
- **`release.yaml`** (on push to `main`, plus `workflow_dispatch`) — a cheap - **`translations.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**)
`detect` job reads `versionName` and checks whether a tag for it already — an SDK-free parity check (`scripts/check_translations.py`) over
exists. Only when it doesn't does the `release` job run: unit tests on the `values-*/strings.xml`, so Weblate PRs get fast feedback. Runs on every PR
merged commit, pin `versionCode`, build & sign the release APK with the **app without a path filter, so the required `Translations / check` status is always
key**, copy it into the F-Droid repo, generate the per-version changelog from reported.
the fastlane tree, re-sign the index with the **repo key**, upload `repo/` + - **`renovate.yml`** (`.gitea/workflows/`, Mondays 05:00 UTC plus
`metadata/`, then create the `vX.Y.Z` tag + Gitea release (CHANGELOG section as `workflow_dispatch`, **Gitea**) — the dependency sweep. Runs the pinned
notes, flagged pre-release while `MAJOR` is 0), attach the R8 `mapping.txt`, Renovate image as its job container and targets Codeberg's API; the cadence
and mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum lives here, not in `renovate.json5`, so the two don't double-gate.
(both best-effort). Ordinary merges with no version bump fall through `detect` - **`release.yaml`** (`.gitea/workflows/`, on push to `main` plus
and do nothing. `workflow_dispatch`, **Gitea**) — a cheap `detect` job reads `versionName` and
checks **Codeberg** for a tag for it. Only when there isn't one does the
`release` job run: unit tests on the merged commit, pin `versionCode`, build &
sign the release APK with the **app key**, copy it into the F-Droid repo,
generate the per-version changelog from the fastlane tree, re-sign the index
with the **repo key**, upload `repo/` + `metadata/`, then create the `vX.Y.Z`
tag + Gitea release (CHANGELOG section as notes, flagged pre-release while
`MAJOR` is 0), attach the R8 `mapping.txt`, and publish the release on
**Codeberg** with the signed APK + a SHA-256 checksum. Ordinary merges with no
version bump fall through `detect` and do nothing.
### Codeberg direct-download channel ### Codeberg direct-download channel
Alongside F-Droid, each release is mirrored to the Codeberg repo Alongside F-Droid, each release is published on the Codeberg repo
(`jlmakiola/agendula`) as a plain download for users who don't want F-Droid. (`jlmakiola/agendula`) as a plain download for users who don't want F-Droid.
Gitea already **push-mirrors** branches and tags to Codeberg, but releases Releases aren't git objects and don't sync with the push mirror in either
aren't git objects and don't sync, so the pipeline creates the release over the direction, so the pipeline pushes the `vX.Y.Z` tag straight to Codeberg, creates
Codeberg API and attaches `agendula_v<version>.apk` + its `.sha256`. It's the the release over the Codeberg API, and attaches `agendula_v<version>.apk` + its
same APK the F-Droid repo serves (same **app key**), so it adds no trust surface. `.sha256`. It's the same APK the F-Droid repo serves (same **app key**), so it
The step is best-effort: a Codeberg outage never fails an already-published adds no trust surface. It skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset,
F-Droid release, and it skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset. but it is **not** `continue-on-error`: through 0.2.10.3.2 this step reported
green while never once publishing, which is how a crash-fix release reached
F-Droid but not the Codeberg/Obtainium users who needed it. A broken mirror
fails the release loudly.
One-time setup: the Codeberg repo's **Releases** unit must be enabled and a One-time setup: the Codeberg repo's **Releases** unit must be enabled and a
`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope) `CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope
added to Gitea Actions. — it pushes the tag as well as creating the release) added to Gitea Actions.
### Manual re-sign / recovery ### Manual re-sign / recovery
@@ -120,6 +137,51 @@ rotation or repo recovery without publishing a new app version.
--- ---
## Two forges, one repo
**Codeberg (`jlmakiola/agendula`) is canonical** — git, issues, PRs, tags and
releases. The self-hosted Gitea instance is build infrastructure: it holds the
signing key, publishes the F-Droid repo, and runs the release pipeline. Codeberg
push-mirrors `main` and tags to Gitea, and a bumped `versionName` arriving there
triggers `release.yaml` exactly as before.
Workflows are separated by **directory**, not by conditionals. Forgejo looks in
`.forgejo/workflows` → `.gitea/workflows` → `.github/workflows` and stops at the
first that exists; Gitea doesn't know `.forgejo/` at all:
| Directory | Runs on | Contains | Secrets |
| --- | --- | --- | --- |
| `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** |
| `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | signing key, F-Droid, Hetzner, bot tokens |
The line is drawn at **secrets, not at CI-vs-release**. That's what makes fork
PRs safe: everything a contributor can trigger lives in `.forgejo/` and can
reference no secret. Renovate stays on the Gitea runner *even though it opens
PRs on Codeberg* — it talks to Codeberg's API rather than moving its token onto
the contributor-facing runner. `detect` (and the Renovate job) additionally
carries a
`github.repository_owner == 'makiolaj'` guard, because the directory split only
holds while `.forgejo/` is non-empty — empty it and Codeberg would fall back to
`.gitea/` and run the release pipeline on the contributor-facing runner.
Two consequences worth remembering:
- **`detect` reads tags from Codeberg**, not from the Gitea instance it runs on.
Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by
the next sync until the Codeberg tag push propagates back. Asking Gitea inside
that window would re-cut a shipped release.
- **Any ref that exists only on Gitea gets deleted** by the mirror. That's
correct under Codeberg-canonical, but don't debug a "vanished" branch without
remembering it.
`floret-kit` is a submodule of this repo and follows the same move: `.gitmodules`
points at `https://codeberg.org/jlmakiola/floret-kit.git`, so a clone resolves
without reaching the personal Gitea instance. The Gitea copy is **kept** — every
existing tag records the old submodule URL, so rebuilds of past releases still
resolve.
---
## Secrets (Gitea → repo Settings → Actions → Secrets) ## Secrets (Gitea → repo Settings → Actions → Secrets)
The workflow fails loudly if the F-Droid ones are missing — it will **never** The workflow fails loudly if the F-Droid ones are missing — it will **never**
@@ -133,7 +195,9 @@ user's pinned repo).
| `FDROID_CONFIG_BASE64` | F-Droid `config.yml` (base64) — repo metadata + keystore passwords. | | `FDROID_CONFIG_BASE64` | F-Droid `config.yml` (base64) — repo metadata + keystore passwords. |
| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | Upload target for the F-Droid repo. | | `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. | | `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. | | `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope) — pushes the tag to Codeberg, creates the release there and uploads the APK/checksum. If unset the step skips; if set and failing, the release fails. |
| `RENOVATE_TOKEN` | Codeberg bot-account token — repo read/write + PR scope on `jlmakiola/agendula`. Used only by `renovate.yml`. |
| `GITHUB_COM_TOKEN` | Read-only github.com PAT (no scopes). Without it Renovate's changelog lookups hit the 60/h anonymous rate limit and PRs arrive with empty release notes. |
The app key signs APKs; the repo key signs the index (its fingerprint is what The app key signs APKs; the repo key signs the index (its fingerprint is what
users pin). Neither key nor `config.yml` is ever uploaded to the server — they users pin). Neither key nor `config.yml` is ever uploaded to the server — they

357
docs/STORAGE-AND-SYNC.md Normal file
View File

@@ -0,0 +1,357 @@
# Agendula — storage and sync
> Decided direction, captured 2026-08-01. Supersedes the earlier "Posture B =
> bundle OpenTasks" working notes, which are withdrawn (see
> [Dead ends](#dead-ends--do-not-revisit)). This is the detailed companion to
> `ARCHITECTURE.md` §7 and the `ProviderResolver` comments, **and it redefines
> what Posture B means** — those two need a follow-up edit.
> `ROADMAP.md` / `PLAN.md` remain known-stale and are due a deliberate pass;
> this document does not attempt it.
## The plan, in short
**What we're building**
1. **Our own provider.** Vendor the Apache-2.0 dmfs task provider in-tree as
`:provider`, renamed to our own authority and permission namespace. Our
database, our namespace — coexists with everything, replaces nothing.
2. **Our own sync.** An Agendula sync adapter, so remote storage never depends
on another app's roadmap.
3. **The user picks the mode.** Local-only · Synced · External provider.
4. **Least permission.** Ask only for what the chosen mode needs, when it needs
it.
5. **Kit-first.** Anything that isn't task-domain goes to floret-kit.
**In what order**
| # | Step | Why now |
|---|---|---|
| 1 | Merge `fix/provider-interaction-review` | unmerged and rotting; touches the same permission flow as step 2 |
| 2 | Vendor `:provider` under our own authority | the identity, done once — and it ships a complete local-first app |
| 3 | Export / backup | our data now lives only in our app's private storage |
| 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users |
| 5 | Sync adapter | the 1.x arc; design discussion pending |
Everything below is the reasoning behind those choices, the alternatives that
were rejected, and the constraints they have to survive.
---
## The decision
Agendula gets its **own identity all the way down** — its own task database
under its own authority, its own sync, and a storage mode the user picks. It
does not adopt, replace, or impersonate another project's provider.
Four parts:
1. **Own DB, bundled in-process.** Vendor the Apache-2.0 dmfs
`opentasks-provider` as an in-tree `:provider` Gradle module, renamed to
authority `de.jeanlucmakiola.agendula.tasks` with permissions
`de.jeanlucmakiola.agendula.permission.READ_TASKS` / `…WRITE_TASKS`. Not a
separate provider *app*; not a schema written from scratch. We keep the dmfs
`TaskContract` shape — it's proven, it's what our whole data layer already
speaks, and it's what every CalDAV engine already understands — we just own
the namespace it lives in.
2. **Own sync adapter**, so remote storage never depends on another app's
roadmap. Protocol coverage is deliberately open — separate discussion.
3. **The user chooses the backend**: local-only, synced, or an external provider
that's already on the device.
4. **Ask for only what the chosen mode actually needs**, at the moment it needs
it.
Plus a standing rule: **anything that isn't task-domain goes to floret-kit.**
### The vocabulary, redefined
`ARCHITECTURE.md` §7 and `ProviderResolver`'s KDoc still describe Posture B as
"bundle OpenTasks and find `org.dmfs.tasks` first." Replace with:
- **Posture A** — front-end over an *external* provider (OpenTasks, tasks.org).
Still fully supported; it stops being the default and becomes a **user
choice**.
- **Posture B** — our own bundled provider under **our own** authority.
Coexists with everything; replaces nothing.
The A/B seam itself is unchanged and still earns its keep: `ProviderResolver` is
the only thing that knows an authority, `AndroidTasksDataSource` the only thing
that touches a resolver. UI, ViewModels, domain and repository are untouched by
all of this.
---
## Why not squat `org.dmfs.tasks`
The rejected plan was to bundle the provider under dmfs's own authority so
DAVx5 would sync into it unwittingly. Reasons it's out, in order of how badly
each one bites:
1. **It is a structural identity mismatch, and the resulting bug is invisible to
both sides.** Content-provider *authorities* are how a sync engine finds a
provider, but Android **account visibility is keyed by package name**: since
API 26 an app only sees accounts whose authenticator has made them visible to
*its package*, and `GET_ACCOUNTS` alone no longer suffices. A sync engine's
allowlist would name the package `org.dmfs.tasks`, not
`de.jeanlucmakiola.agendula`. So the bundled provider could find **zero**
accounts — and the dmfs provider uses account enumeration to prune task lists
whose account has gone away. The failure mode isn't "no sync", it's "our
provider quietly purges synced lists." *(Reasoned from the platform rules,
not from having read DAVx5's source — but the class of bug is structural, and
every future place anything keys on package rather than authority is a fresh
instance of it.)* An explicit integration under our own name makes this bug
impossible by construction.
2. **Play Store install-time landmine.** Two apps cannot declare the same
authority (`INSTALL_FAILED_CONFLICTING_PROVIDER`) or the same `<permission>`
name (`INSTALL_FAILED_DUPLICATE_PERMISSION`, waived only for identical
signing certs). Anyone with OpenTasks installed gets a failed install,
surfacing as one-star reviews we can't usefully answer.
3. **Migration data loss.** "Uninstall OpenTasks first" takes its DB with it.
CalDAV-synced tasks reconcile back; local-only tasks are simply gone, and
OpenTasks has no export (dmfs/opentasks #170, #204, #71 — years-old,
unimplemented; their wiki punts to a desktop client).
4. **Squatting another project's namespace doesn't scale.** At low install
counts nobody notices. At scale we'd be generating issues on dmfs's tracker
that aren't dmfs's fault, and silently maintaining a schema fork under their
name.
---
## The `:provider` module
**Source.** dmfs `opentasks-provider`, Apache-2.0. Target the **1.4.2** source
(DB version 23 — the version that actually carries `is_recurring`; tasks.org's
fork is DB 22 and lacks it, which is why `TaskMapper.task` reads `rrule`/`rdate`
for recurrence detection rather than trusting the column).
**Layout: in-tree module, not a git submodule.** floret-kit is a submodule
because we co-develop it. This is a fork we will sync from upstream
approximately never, so in-tree is simpler for both stores and honest about what
it is. Ship a `provider/PROVENANCE.md`: upstream commit, and every change we
made.
**License hygiene, on day one.** The app is MIT, the provider is Apache-2.0 —
permissive into permissive, fine — but the module keeps its Apache-2.0 headers,
`LICENSE`, and `NOTICE`. Ten minutes now; embarrassing to retrofit once it's in
two store listings.
**What we change:**
- Authority → `de.jeanlucmakiola.agendula.tasks` (it's already a string
resource, `opentasks_authority`).
- Permission names → `de.jeanlucmakiola.agendula.permission.*`. These are
**hardcoded in the AAR manifest**, which is the single clearest reason
vendoring is mandatory rather than merely preferable — you cannot rename them
in a prebuilt artifact without `tools:` node surgery we'd rather not ship.
- Drop `<uses-permission android:name="android.permission.GET_ACCOUNTS" />`.
We own our own accounts, so we don't need it — but note the provider's
account-cleanup path is written *assuming* it, so this is a review-and-rework
item, not a free deletion. **Verify** the provider's local-list/local-account
path works with no account present at all; that's the entire local-only mode.
- Drop the exported `BOOT_COMPLETED` / `TIME_SET` / `TIMEZONE_CHANGED` receiver,
or keep it deliberately and give it an explicit `android:exported`. The AAR is
from the `targetSdk 29` era; AGP hard-errors on a merged manifest with an
intent-filtered component and no explicit `exported` once targetSdk ≥ 31, and
we're on 36. Fixed at source instead of patched around.
- Modernize the build: it ships `minSdk 21` / `targetSdk 29`, Robolectric 3.5.1,
JUnit 4.12. We're `minSdk 29` / `targetSdk 36` and Play raises its target-API
floor annually, so this isn't optional upkeep.
**Our data now lives in our app's private storage.** Uninstall means deletion.
That single fact is what promotes export/backup from "nice to have" to a v1
feature — see [Storage modes](#storage-modes--the-users-choice).
---
## Storage modes — the user's choice
| Mode | Backing store | Sync | Needs |
|---|---|---|---|
| **Local** | our bundled provider | none | no permissions at all — same-uid provider access needs no grant |
| **Synced** | our bundled provider | our sync adapter | network + an account the user configures |
| **External** | OpenTasks / tasks.org | whatever that provider's engine does (DAVx5 …) | that provider's `READ`/`WRITE_TASKS`, granted at runtime |
Local and Synced are the same store — Synced is Local with an account attached,
so switching on sync is not a migration.
**Resolver ordering needs deciding.** Today `ProviderResolver.CANDIDATES` is a
fixed priority list and the first hit wins. Once we bundle our own provider,
"first hit" is the wrong rule: someone who used Agendula locally and *later*
installs DAVx5 + OpenTasks would see an external candidate outrank the provider
that actually holds their data. Options: rank ours first whenever it's
non-empty, or make the mode an explicit Settings choice (it's user-visible
either way, so probably both — auto-pick a sane default, let Settings override).
**Export/backup is a v1 feature.** Not, as previously framed, a migration safety
net for "uninstall OpenTasks" — that scenario no longer exists. It's data
portability for Local-mode users, whose tasks otherwise exist in exactly one
place with no second copy. On Play, where most users won't have a sync engine,
that's the majority.
---
## Sync — own adapter
**Decided:** Agendula ships its own sync. Not because DAVx5 is bad, but because
depending on it makes one external maintainer's roadmap the gate on our core
feature — the same shape of dependency the whole identity decision exists to
escape.
The two precedents diverge and the choice between them is the whole point:
tasks.org has its own authority **and its own sync** (sovereign); jtx Board has
its own authority and **depends on DAVx5** (and got added, though a working
relationship with bitfire is part of that story). We're taking the tasks.org
shape.
**Play sharpens this.** DAVx5 is a paid app on Google Play and free only on
F-Droid — *worth confirming, since it's load-bearing* — which means most Play
users will never have it. Lobbying bitfire is therefore an **F-Droid-audience
feature, not a sync strategy**.
**Still file the DAVx5 issue** — cheap, non-blocking, real value for F-Droid
users. And make it the strongest possible version of the ask: our provider *is*
the dmfs provider with renamed strings, so it's byte-identical contract
compliance and a small enum-shaped addition with near-zero ongoing maintenance
for them. Say that explicitly. "Here's a change that can't break anything" lands
very differently from "please support my app."
**Open — the next discussion.** Protocol coverage ("support as much as
possible"), the account model, conflict resolution, and where the DAV/iCalendar
work lives. One constraint to settle early: we're MIT; `dav4jvm` is Apache-2.0
and fine, but **verify `ical4android`'s license** before assuming it's usable.
---
## Permissions — only what the mode needs
The manifest is static, so "request only what we need" is really two different
mechanisms, and conflating them is how apps end up over-permissioned:
- **Runtime (dangerous) permissions** — genuinely stageable. Ask at the moment
the feature is used, never up front.
- **Install-time (normal) permissions** — declared unconditionally; the only
lever is **not declaring them until the feature ships**, and not letting a
bundled dependency drag in ones we don't use.
| Permission | When |
|---|---|
| *(none)* for our own provider | same-uid access needs no grant — `ProviderStatus.NEEDS_PERMISSION` must never fire in Local/Synced mode |
| `org.dmfs.permission.*`, `org.tasks.permission.*` | requested **only** when the user selects External mode; declared always (static manifest) |
| `POST_NOTIFICATIONS` | when reminders are first enabled |
| `USE_EXACT_ALARM` / `SCHEDULE_EXACT_ALARM` | when exact due-time reminders are used. Note Play reviews `USE_EXACT_ALARM` and requires the app to be a calendar/alarm/task app — we qualify, but it needs a justification in the listing |
| `INTERNET` | **don't declare it until sync ships** |
| `GET_ACCOUNTS` | never — stripped from the vendored provider; our own account type doesn't need it to see its own accounts |
**Work item:** the permission gate in `RootScreen` / `PermissionViewModel` /
`ProviderResolver.hasPermission` currently assumes an external provider always
needs a grant. It needs a bypass for our own provider. Modest, but it's the
exact flow `fix/provider-interaction-review` just touched — merge that first.
---
## What lands in floret-kit
Standing rule, matching the kit's own thesis (*share the mechanics, keep the
look — the kit never knows about a specific app's domain*): if it isn't
task-domain, it goes to the kit.
| Candidate | Kit module | Note |
|---|---|---|
| ContentProvider seam — `ColumnReader`, failures, observer→Flow | `core-provider` | **already on the kit's deferred list**, blocked on migrating Calendula to the name-based reader. Bundling our own provider is the forcing function that makes this worth doing. |
| Runtime-permission staging — request/state machine, rationale plumbing, "ask at point of use" | new, e.g. `core-permissions` | pure mechanics, and Calendula has the identical problem |
| DAV client + iCalendar parse/serialize | new, e.g. `core-dav` | **the big one.** Calendula is a calendar app; it needs the same primitives. Worth designing for two consumers from the start rather than extracting later |
| Export/backup plumbing — SAF, file writing, share-out | kit | the *serialization* of tasks is domain; the plumbing isn't |
| Sync-adapter/account scaffolding | kit, probably | the `AbstractThreadedSyncAdapter` + authenticator boilerplate is identical everywhere; the delta logic is domain |
**Stays app-local:** the vendored `:provider` module (task-specific, and
Apache-2.0 against the kit's MIT), `TaskContract` and the mappers, domain models
and smart lists, all screens, and the reminder *scheduler* (per the kit's
existing "not shared" call — Agendula pulls, Calendula pushes).
---
## Distribution — F-Droid and Play from day one
Not on either yet; both are targets, so build *for* them rather than retrofitting.
- **F-Droid** requires from-source. The in-tree `:provider` module satisfies it;
a JitPack artifact would not. floret-kit's composite build already keeps the
kit from-source, and the reproducibility guard (`vcsInfo { include = false }`
in `app/build.gradle.kts`) is already in place.
- **Play** requires a rising target-API floor, a data-safety declaration, and
justification for `USE_EXACT_ALARM`. It also means dangerous permissions we
don't use are a liability, not just clutter — which is most of why
`GET_ACCOUNTS` and the stray receiver come out of the vendored provider.
- **Parked, not solved:** dual-distribution signing. F-Droid reproducible builds
verify against *our* signed APK; Play App Signing re-signs with Google's key.
Both can coexist, but it needs a deliberate pass before the first Play upload.
See `RELEASING.md`.
---
## Dead ends — do not revisit
- **One APK that detects at install time and adapts.** Impossible.
`<provider>` authorities and `<permission>` declarations are frozen at build
time and read by the OS at install; there is no install-time hook where our
code runs. And unlike a component, a `<permission>` cannot be runtime-toggled
— no `setComponentEnabledSetting` equivalent.
- **Dynamic feature modules** to deliver the provider conditionally. Conditions
are limited to hardware features / SDK / country — there is no "only if app X
is absent" — and they require Play, so they're dead for F-Droid regardless.
- **`frontend` / `standalone` build flavors.** Two flavors means two
`applicationId`s (two listings, two signing lines, and switching costs a user
their local data), or one `applicationId` and they can't coexist in a repo
anyway. Obsolete now that our provider coexists with everything instead of
replacing anything.
- **Maven Central for the provider.** `org.dmfs:opentasks-provider` *is* there —
but only up to `1.1.8.1` (2016, `jar` packaging, 3 versions). No DB 23.
Verified.
- **JitPack (`com.github.dmfs.opentasks:opentasks-provider:1.4.2`).** Has the
right version, but it's a prebuilt artifact (fails F-Droid from-source), it
can't have its hardcoded permission names renamed, and adding JitPack widens
the dependency trust surface — `settings.gradle.kts` is currently `google()` +
`mavenCentral()` only, under `FAIL_ON_PROJECT_REPOS`. *Still usable for a
throwaway spike* (a library string resource can be overridden from the app
module, so the authority rename works), but not for anything we ship.
---
## Sequencing
1. **Merge `fix/provider-interaction-review`** (`47cf99a`, currently unmerged
into `main`). It's blocking nothing and rotting, and it touches the exact
permission flow step 2 changes.
2. **Vendor `:provider`** under our own authority and permission namespace, with
the permission-gate bypass. This is the identity, done once, done right —
and it ships a complete local-first app to both stores.
3. **Export/backup.** Now a v1 feature, not a migration hack.
4. **File the DAVx5 issue.** Non-blocking, cheap, serves F-Droid users.
5. **Sync adapter.** The 1.x arc; design discussion pending.
**Scope honesty:** the withdrawn notes costed this at "24 days shippable, +1
week for F-Droid." Steps 25 are a substantially larger program than that, and
the roadmap should say so rather than inheriting the old estimate.
---
## Open questions
1. **Sync protocol coverage**, account model, conflict resolution — the next
discussion.
2. **Resolver ordering / mode selection UX** once our provider coexists with
external ones (see [Storage modes](#storage-modes--the-users-choice)).
3. **Does the vendored provider work with no account at all?** Local-only mode
depends on it entirely. First thing the vendoring work should prove.
4. **`ical4android` licensing** vs our MIT.
5. **jtx Board** as an additional External-mode candidate — richer contract,
later. (`PLAN.md` decision #3, still open.)
---
## Related
The provider-interaction review on `fix/provider-interaction-review` fixed,
among others: recurrence-aware editing (routes through the instances URI),
all-day UTC handling, the `DUE`/`DURATION` collision, per-task reminders (Alarm
property rows), and flow-recovery robustness. That makes `ROADMAP.md` open
decision #5 ("recurrence-aware editing out of scope for v1") **stale**.

View File

@@ -6,5 +6,5 @@ Summary: A modern Material 3 Expressive task app for Android.
Categories: Categories:
- Time - Time
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/agendula SourceCode: https://codeberg.org/jlmakiola/agendula
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/agendula/issues IssueTracker: https://codeberg.org/jlmakiola/agendula/issues

View File

@@ -1,5 +0,0 @@
### Fixed
- Agendula no longer crashes on launch. Every 0.3.0 install was affected: the
release build stripped a constructor that the background-work scheduler needs
to open its database, and that happens before the app draws anything.

122
renovate.json5 Normal file
View File

@@ -0,0 +1,122 @@
{
$schema: "https://docs.renovatebot.com/renovate-schema.json",
extends: [
"config:recommended",
// chore(deps): … — match the repo's conventional-commit style.
":semanticCommits",
],
// `config:recommended` brings in mergeConfidence:age-confidence-badges, whose
// Age column is a Mend badge. Mend's Merge Confidence index only covers Maven
// Central: org.jetbrains.kotlin, junit, truth, turbine et al resolve, but
// every androidx/compose artifact lives on Google's Maven repo and comes back
// as a grey UNKNOWN — i.e. most of this project. Renovate already knows the
// real answer, since it derives release timestamps itself for the
// minimumReleaseAge rules below (Google Maven serves `last-modified` on its
// POMs), so take the age from there and leave Mend to the Confidence column,
// which still carries signal for the Maven Central half.
prBodyDefinitions: {
Age: "{{#if releaseTimestamp}}{{{newVersionAgeInDays}}} d{{else}}unknown{{/if}}",
},
// Default heading links to the Merge Confidence docs; this column is ours now.
prBodyHeadingDefinitions: {
Age: "Age",
},
// No automerge: a dependency bump goes through the same review (and, for
// anything touching the build, the same on-device check) as a feature
// before it can ride a release — see docs/RELEASING.md and the mandatory
// `scripts/verify-release.sh` gate.
automerge: false,
// One reviewable surface; the dashboard issue lists everything pending.
dependencyDashboard: true,
// The cooling-off periods below are advisory, not a gate. "flexible" still
// prefers a version that has cleared its window, but when every candidate is
// too young it opens the PR at the newest one anyway, so merging early stays
// a judgement call. (The default, "strict", would suppress the PR entirely
// until a release aged in.) A still-young branch carries a yellow
// `renovate/stability-days` check so it's visible which side of the line
// it's on; with automerge off, nothing acts on that check by itself.
//
// NOT "none": that short-circuits the candidate loop in filter-checks.ts, and
// that loop is what calls postprocessRelease — the only thing that fetches a
// Maven artifact's Last-Modified header. Skipping it leaves releaseTimestamp
// unset, which empties the Age column and quietly makes minimumReleaseAge and
// the stability check no-ops, since both need that timestamp to compare.
internalChecksFilter: "flexible",
labels: ["dependencies"],
prConcurrentLimit: 5,
prHourlyLimit: 0,
// Cadence is owned by the Gitea Actions cron (.gitea/workflows/renovate.yml,
// Mondays) — no internal `schedule` here, so the two don't double-gate and
// silently skip a run.
// Workflows are split by forge and neither directory is .github: CI lives in
// .forgejo/workflows (Codeberg) and the release/renovate jobs in
// .gitea/workflows (Gitea). Extend the github-actions manager (same syntax)
// to watch both — otherwise the pinned Renovate image tag and the action
// versions in either file would never get bumped. See docs/RELEASING.md.
// `fileMatch` is deprecated; the replacement takes the regex delimited, and
// Renovate's config migration was already rewriting this on every run.
"github-actions": {
managerFilePatterns: ["/^\\.(gitea|forgejo)/workflows/[^/]+\\.ya?ml$/"],
},
packageRules: [
// Cooling-off period, scaled by blast radius: how long a release should
// have been out (and un-yanked, un-hotfixed) before it's considered
// settled. Advisory only — see `internalChecksFilter` above.
{
matchUpdateTypes: ["major"],
minimumReleaseAge: "30 days",
},
{
matchUpdateTypes: ["minor"],
minimumReleaseAge: "20 days",
},
{
matchUpdateTypes: ["patch", "digest", "pin", "rollback"],
minimumReleaseAge: "10 days",
},
// material3 is deliberately pinned to the 1.5 *alpha* line for the
// Expressive APIs (see gradle/libs.versions.toml). Follow the alpha train
// but keep it in its own PR, reviewed in isolation; revisit the pin when
// 1.5.0 stable lands.
{
matchPackageNames: ["androidx.compose.material3:material3"],
ignoreUnstable: false,
groupName: "material3 (alpha)",
},
// Test-only deps: group into one low-noise PR.
{
matchPackageNames: [
"org.junit.jupiter:**",
"org.junit.platform:**",
"com.google.truth:**",
"app.cash.turbine:**",
"androidx.test:**",
"androidx.test.espresso:**",
"androidx.test.ext:**",
],
groupName: "test dependencies",
},
// Last word on the PR table. The merge-confidence preset sets prBodyColumns
// from inside a packageRule of its own, and only for the datasources Mend
// supports — so a plain top-level prBodyColumns would lose to it for maven
// deps, and the Gradle wrapper / Actions / container bumps would keep the
// default columns and show no age at all. A rule declared after it wins,
// and gives every PR the same table.
// "Pending" earns its place under a flexible filter: when the bump lands on
// a version that has cleared its window but a newer one hasn't, that newer
// version is named here rather than silently withheld.
{
matchPackageNames: ["*"],
prBodyColumns: ["Package", "Type", "Change", "Age", "Pending", "Confidence"],
},
],
}

94
scripts/check_translations.py Executable file
View File

@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Validate Android translation resources against the base strings.xml.
Community translations live in ``app/src/main/res/values-<locale>/strings.xml``
and are produced via Weblate. This guard keeps incoming translation PRs honest:
* every translation file must be well-formed XML;
* a translation must not define keys absent from the base — those are stale
keys left behind after a rename/removal upstream;
* a translation must not translate strings marked ``translatable="false"`` in
the base (URLs, IDs and the like).
Missing keys are *allowed* and only reported as coverage: a missing string
falls back to the English base at runtime, so partial translations are fine
(this mirrors the lint config, which downgrades ``MissingTranslation``).
Exits non-zero if any error is found. Errors are emitted as Gitea/GitHub
Actions ``::error`` annotations so they surface inline on the PR.
"""
from __future__ import annotations
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
RES_DIR = Path("app/src/main/res")
BASE = RES_DIR / "values" / "strings.xml"
RESOURCE_TAGS = ("string", "plurals", "string-array")
def entries(path: Path) -> dict[str, bool]:
"""Map resource name -> is-translatable for every entry in ``path``."""
root = ET.parse(path).getroot()
return {
el.attrib["name"]: el.attrib.get("translatable", "true") != "false"
for el in root
if el.tag in RESOURCE_TAGS and "name" in el.attrib
}
def main() -> int:
if not BASE.exists():
print(f"::error::base resource file {BASE} not found", file=sys.stderr)
return 1
base = entries(BASE)
base_keys = set(base)
nontranslatable = {name for name, ok in base.items() if not ok}
translatable_total = len(base_keys - nontranslatable)
files = sorted(RES_DIR.glob("values-*/strings.xml"))
if not files:
print("No translation files found (values-*/strings.xml).")
return 0
errors = 0
for path in files:
locale = path.parent.name[len("values-"):]
try:
translated = entries(path)
except ET.ParseError as exc:
print(f"::error file={path}::{locale}: malformed XML: {exc}")
errors += 1
continue
keys = set(translated)
stale = sorted(keys - base_keys)
translated_fixed = sorted(keys & nontranslatable)
missing = base_keys - nontranslatable - keys
for name in stale:
print(f"::error file={path}::{locale}: stale key '{name}' is not in the base strings.xml")
errors += 1
for name in translated_fixed:
print(
f"::error file={path}::{locale}: key '{name}' is translatable=\"false\" "
"in the base and must not be translated"
)
errors += 1
covered = translatable_total - len(missing)
pct = covered * 100 // translatable_total if translatable_total else 100
verdict = "OK" if not (stale or translated_fixed) else "FAIL"
print(f"{locale:<10} {covered}/{translatable_total} keys ({pct}%) — {verdict}")
if errors:
print(f"\n{errors} translation error(s) found.", file=sys.stderr)
return 1
print("\nAll translation files are consistent with the base.")
return 0
if __name__ == "__main__":
sys.exit(main())