4 Commits

Author SHA1 Message Date
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
56 changed files with 1371 additions and 815 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 —
# republishing to F-Droid. Failing here is recoverable; a duplicate
# release is not.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
case "$STATUS" in
200)
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
echo "is_release=false" >> "$GITHUB_OUTPUT" echo "is_release=false" >> "$GITHUB_OUTPUT"
else ;;
echo "No tag for v$VERSION yet — cutting the release." 404)
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
echo "is_release=true" >> "$GITHUB_OUTPUT" echo "is_release=true" >> "$GITHUB_OUTPUT"
fi ;;
*)
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
# 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 \ curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
-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/$EXIST"
else ID="$EXIST"; break
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)
fi fi
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id." >&2; exit 1; fi CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases")
echo "release POST attempt $attempt HTTP $CODE"
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
[ -n "$ID" ] && break
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

@@ -3,7 +3,6 @@ package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
@@ -46,14 +45,7 @@ class DueReminderReceiver : BroadcastReceiver() {
companion object { companion object {
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID" private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
/** fun intent(context: Context, taskId: Long): Intent =
* [triggerAt] rides in the intent *data*, not just an extra: PendingIntent Intent(context, DueReminderReceiver::class.java).putExtra(EXTRA_TASK_ID, taskId)
* identity ignores extras, so two occurrences of the same recurring task
* would otherwise collapse into one alarm under FLAG_UPDATE_CURRENT.
*/
fun intent(context: Context, taskId: Long, triggerAt: Long): Intent =
Intent(context, DueReminderReceiver::class.java)
.setData("agendula://reminder/$taskId/$triggerAt".toUri())
.putExtra(EXTRA_TASK_ID, taskId)
} }
} }

View File

@@ -3,9 +3,7 @@ package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.SystemClock
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -22,25 +20,10 @@ import javax.inject.Inject
class ProviderChangeReceiver : BroadcastReceiver() { class ProviderChangeReceiver : BroadcastReceiver() {
@Inject lateinit var scheduler: ReminderScheduler @Inject lateinit var scheduler: ReminderScheduler
@Inject lateinit var providerResolver: ProviderResolver
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
// The receiver has to stay exported to hear the provider's broadcast, and
// the sender holds no permission we could require — so validate the
// broadcast itself. Without this, any installed app can spam a full
// re-sync (an unbounded provider read) by firing a matching intent.
if (intent.action != Intent.ACTION_PROVIDER_CHANGED) return
val authority = providerResolver.resolve()?.authority ?: return
if (intent.data?.host != authority) return
// External sync can fire these in bursts; one re-sync per burst is plenty.
val now = SystemClock.elapsedRealtime()
synchronized(Companion) {
if (now - lastSyncAt < MIN_SYNC_INTERVAL_MS) return
lastSyncAt = now
}
val pending = goAsync() val pending = goAsync()
scope.launch { scope.launch {
try { try {
@@ -50,11 +33,4 @@ class ProviderChangeReceiver : BroadcastReceiver() {
} }
} }
} }
private companion object {
const val MIN_SYNC_INTERVAL_MS = 10_000L
@Volatile
var lastSyncAt = -MIN_SYNC_INTERVAL_MS
}
} }

View File

@@ -43,57 +43,35 @@ class ReminderScheduler @Inject constructor(
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) } val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
.getOrElse { return@withContext } .getOrElse { return@withContext }
// One reminder per *occurrence*: the instances view yields a row per
// occurrence, all sharing a taskId, so this is a Set rather than a
// taskId-keyed Map — keying by task would collapse a daily recurring task
// down to one arbitrary reminder (the query is unsorted, so which one
// survived was provider-defined).
// Per-task leads, stored as Alarm property rows. One query for all of them.
val perTask = runCatching { dataSource.alarms() }.getOrElse { emptyMap() }
val desired = tasks val desired = tasks
.filter { !it.isClosed && it.due != null } .filter { !it.isClosed && it.due != null }
.mapNotNull { task -> .mapNotNull { task ->
// A reminder set on the task itself wins; otherwise the task's list // The task's list may override the global lead, or opt out entirely
// may override the global lead, or opt out entirely (override = // (override = null), in which case it gets no reminder at all.
// null), in which case it gets no reminder at all. val lead = settings.reminderLeadFor(task.listId) ?: return@mapNotNull null
val lead = perTask[task.taskId] task.taskId to (task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L)
?: settings.reminderLeadFor(task.listId)
?: return@mapNotNull null
ScheduledReminder(
taskId = task.taskId,
triggerAt = task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L,
)
} }
// The lower bound trails `now` so a reminder missed while the device was .toMap()
// off still fires once on boot instead of being silently dropped — .filterValues { it in now..horizon }
// setExactAndAllowWhileIdle delivers a past trigger immediately. Anything
// already armed stays armed (the diff below), so it can't re-fire.
.filter { it.triggerAt in (now - MISSED_GRACE_MS)..horizon }
.toSet()
val previous = store.all() val previous = store.all()
(previous - desired).forEach { cancel(it) } (previous.keys - desired.keys).forEach { cancel(it) }
(desired - previous).forEach { schedule(it) } desired.forEach { (taskId, triggerAt) ->
if (previous[taskId] != triggerAt) schedule(taskId, triggerAt)
}
store.replace(desired) store.replace(desired)
} }
private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java) private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java)
private fun pendingIntent(reminder: ScheduledReminder, create: Boolean): PendingIntent? { private fun pendingIntent(taskId: Long, create: Boolean): PendingIntent? {
val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_IMMUTABLE
return PendingIntent.getBroadcast( return PendingIntent.getBroadcast(context, taskId.toInt(), DueReminderReceiver.intent(context, taskId), flags)
context,
reminder.requestCode,
DueReminderReceiver.intent(context, reminder.taskId, reminder.triggerAt),
flags,
)
} }
private fun schedule(reminder: ScheduledReminder) { private fun schedule(taskId: Long, triggerAt: Long) {
val triggerAt = reminder.triggerAt val pi = pendingIntent(taskId, create = true) ?: return
val pi = pendingIntent(reminder, create = true) ?: return
val am = alarmManager() val am = alarmManager()
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms() val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
if (canExact) { if (canExact) {
@@ -103,21 +81,19 @@ class ReminderScheduler @Inject constructor(
} }
} }
private fun cancel(reminder: ScheduledReminder) { private fun cancel(taskId: Long) {
pendingIntent(reminder, create = false)?.let { pendingIntent(taskId, create = false)?.let {
alarmManager().cancel(it) alarmManager().cancel(it)
it.cancel() it.cancel()
} }
} }
private suspend fun clearAll() { private suspend fun clearAll() {
store.all().forEach { cancel(it) } store.all().keys.forEach { cancel(it) }
store.replace(emptySet()) store.replace(emptyMap())
} }
private companion object { private companion object {
const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
/** How long after its trigger a missed reminder is still worth firing. */
const val MISSED_GRACE_MS = 6L * 60 * 60 * 1000 // 6 hours
} }
} }

View File

@@ -9,38 +9,25 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* One armed alarm. A recurring task has many occurrences sharing a [taskId], so * Remembers which task reminders are currently scheduled (taskId → trigger time),
* the trigger time is part of the identity — keying by task alone would collapse * so [ReminderScheduler] can diff against a fresh computation and cancel only the
* a daily task down to a single reminder. * alarms that changed. Persisted in DataStore as a set of `taskId|trigger` strings.
*/
data class ScheduledReminder(val taskId: Long, val triggerAt: Long) {
/**
* Request code for this alarm's PendingIntent. Derived from both fields so
* sibling occurrences don't share (and overwrite) one alarm slot.
*/
val requestCode: Int get() = (taskId * 31 + triggerAt).hashCode()
}
/**
* Remembers which task reminders are currently armed, so [ReminderScheduler] can
* diff against a fresh computation and touch only the alarms that changed.
* Persisted in DataStore as a set of `taskId|trigger` strings.
*/ */
@Singleton @Singleton
class ScheduledReminderStore @Inject constructor( class ScheduledReminderStore @Inject constructor(
private val dataStore: DataStore<Preferences>, private val dataStore: DataStore<Preferences>,
) { ) {
suspend fun all(): Set<ScheduledReminder> = suspend fun all(): Map<Long, Long> =
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry -> dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
val parts = entry.split('|') val parts = entry.split('|')
val id = parts.getOrNull(0)?.toLongOrNull() val id = parts.getOrNull(0)?.toLongOrNull()
val at = parts.getOrNull(1)?.toLongOrNull() val at = parts.getOrNull(1)?.toLongOrNull()
if (id != null && at != null) ScheduledReminder(id, at) else null if (id != null && at != null) id to at else null
}.toSet() }.toMap()
suspend fun replace(scheduled: Set<ScheduledReminder>) { suspend fun replace(scheduled: Map<Long, Long>) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[KEY] = scheduled.map { "${it.taskId}|${it.triggerAt}" }.toSet() prefs[KEY] = scheduled.entries.map { "${it.key}|${it.value}" }.toSet()
} }
} }

View File

@@ -11,7 +11,6 @@ import android.os.Looper
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
@@ -99,50 +98,6 @@ class AndroidTasksDataSource @Inject constructor(
if (rows == 0) throw TaskWriteFailedException("update task $taskId") if (rows == 0) throw TaskWriteFailedException("update task $taskId")
} }
override fun updateInstance(instanceId: Long, form: TaskForm) {
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
val uri = TasksContract.instanceUri(authority(), instanceId)
val rows = resolver.update(uri, values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
}
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
val uri = TasksContract.propertiesUri(authority())
// Replace rather than update: the provider's AlarmHandler re-validates the
// whole row on every update, so a partial edit throws — and delete+insert
// means we never have to track property_id.
resolver.delete(
uri,
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
arrayOf(taskId.toString(), TasksContract.Alarm.MIMETYPE),
)
if (minutesBeforeDue != null) {
resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues())
?: throw TaskWriteFailedException("set alarm for task $taskId")
}
}
override fun alarms(): Map<Long, Int> {
val uri = TasksContract.propertiesUri(authority())
val projection = arrayOf(Properties.TASK_ID, TasksContract.Alarm.MINUTES_BEFORE)
return resolver.query(
uri,
projection,
"${Properties.MIMETYPE} = ?",
arrayOf(TasksContract.Alarm.MIMETYPE),
null,
)?.use { c ->
val reader = CursorColumnReader(c)
buildMap {
while (c.moveToNext()) {
val id = reader.getLong(Properties.TASK_ID)
val minutes = reader.getInt(TasksContract.Alarm.MINUTES_BEFORE)
if (id != null && minutes != null) put(id, minutes)
}
}
} ?: emptyMap()
}
override fun setCompleted(taskId: Long, completed: Boolean) { override fun setCompleted(taskId: Long, completed: Boolean) {
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis()) val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null) val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
@@ -172,16 +127,9 @@ class AndroidTasksDataSource @Inject constructor(
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) = onChange() override fun onChange(selfChange: Boolean) = onChange()
} }
// Register both or neither: if the second call throws, the first
// registration would otherwise leak (no AutoCloseable was handed back yet).
try {
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer) resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer) resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
} catch (e: RuntimeException) { return AutoCloseable { resolver.unregisterContentObserver(observer) }
runCatching { resolver.unregisterContentObserver(observer) }
throw e
}
return AutoCloseable { runCatching { resolver.unregisterContentObserver(observer) } }
} }
private fun Map<String, Any?>.toContentValues(): ContentValues { private fun Map<String, Any?>.toContentValues(): ContentValues {

View File

@@ -1,31 +0,0 @@
package de.jeanlucmakiola.agendula.data.tasks
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.retryWhen
private const val BASE_RETRY_MS = 1_000L
private const val MAX_RETRY_MS = 30_000L
/** 1s, 2s, 4s … capped at 30s, so a permanently-absent provider costs little. */
private fun retryDelayMs(attempt: Long): Long =
(BASE_RETRY_MS shl attempt.coerceAtMost(5).toInt()).coerceAtMost(MAX_RETRY_MS)
/**
* Recover a provider-backed flow without killing it.
*
* Provider reads fail for reasons that resolve on their own: the read permission
* isn't granted yet (first launch collects before the permission gate), or the
* provider app is mid-update. A terminal `catch` swallows the failure *and*
* cancels the upstream, so the flow never produces again — the screen stays empty
* until the process restarts, even after the user grants the permission.
*
* This emits [fallback] instead and keeps retrying with a capped backoff, so the
* collector recovers on its own once the provider becomes readable.
*/
fun <T> Flow<T>.recoveringFromProviderFailure(fallback: () -> T): Flow<T> =
retryWhen { _, attempt ->
emit(fallback())
delay(retryDelayMs(attempt))
true
}

View File

@@ -38,14 +38,7 @@ object TaskMapper {
listName = r.getString(Tasks.LIST_NAME), listName = r.getString(Tasks.LIST_NAME),
accountName = r.getString(Tasks.ACCOUNT_NAME), accountName = r.getString(Tasks.ACCOUNT_NAME),
parentId = r.getLong(Tasks.PARENT_ID), parentId = r.getLong(Tasks.PARENT_ID),
// Derived from the rule columns rather than the `is_recurring` column isRecurring = r.getBoolean(Instances.IS_RECURRING),
// alone: that column only exists from OpenTasks 1.4.0 (DB 23) and is
// absent on tasks.org's bundled provider (DB 22), where reading it
// would silently report every recurring task as one-off — and route
// its edits onto the series anchor.
isRecurring = r.getString(Tasks.RRULE) != null ||
r.getString(Tasks.RDATE) != null ||
r.getBoolean(Instances.IS_RECURRING),
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT), distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
created = instant(Tasks.CREATED), created = instant(Tasks.CREATED),
lastModified = instant(Tasks.LAST_MODIFIED), lastModified = instant(Tasks.LAST_MODIFIED),

View File

@@ -1,6 +1,8 @@
package de.jeanlucmakiola.agendula.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
/** Column lists requested from the provider. Order is irrelevant; we read by name. */ /** Column lists requested from the provider. Order is irrelevant; we read by name. */
object TaskProjections { object TaskProjections {
@@ -16,9 +18,31 @@ object TaskProjections {
Lists.ACCOUNT_TYPE, Lists.ACCOUNT_TYPE,
) )
// No `instances` projection on purpose: that read passes `projection = null` /** Read from the `instances` view (inherits all task columns). */
// (all columns), because the view's shape differs across provider versions — val INSTANCES: Array<String> = arrayOf(
// tasks.org's bundled OpenTasks has no `is_recurring`, for one. A fixed list Tasks.ID,
// here would drift out of sync with the by-name mapper and quietly drop Instances.TASK_ID,
// columns it depends on. See AndroidTasksDataSource.queryInstances. Tasks.LIST_ID,
Tasks.TITLE,
Tasks.DESCRIPTION,
Tasks.LOCATION,
Tasks.URL,
Tasks.PRIORITY,
Tasks.STATUS,
Tasks.PERCENT_COMPLETE,
Tasks.COMPLETED,
Tasks.IS_ALLDAY,
Tasks.TZ,
Instances.INSTANCE_START,
Instances.INSTANCE_DUE,
Tasks.TASK_COLOR,
Tasks.LIST_COLOR,
Tasks.LIST_NAME,
Tasks.ACCOUNT_NAME,
Tasks.PARENT_ID,
Instances.IS_RECURRING,
Instances.DISTANCE_FROM_CURRENT,
Tasks.CREATED,
Tasks.LAST_MODIFIED,
)
} }

View File

@@ -1,21 +1,9 @@
package de.jeanlucmakiola.agendula.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.toICal import de.jeanlucmakiola.agendula.domain.toICal
import kotlin.time.Instant
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
/** Floor to UTC midnight when [allDay], else pass through unchanged. */
private fun Instant.forAllDay(allDay: Boolean): Instant =
if (!allDay) this
else Instant.fromEpochMilliseconds(
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
)
/** /**
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues), * Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
@@ -51,17 +39,8 @@ object TaskWriteMapper {
} }
} }
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0) put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
// All-day tasks are date-only in iCalendar. The provider reads them back put(Tasks.DTSTART, form.start?.toEpochMilliseconds())
// through DateTime.toAllDay(), which drops the time-of-day and resolves the put(Tasks.DUE, form.due?.toEpochMilliseconds())
// remaining date against UTC — so a local-midnight instant lands on the
// previous day for anyone west of UTC. Pin all-day values to UTC midnight.
put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
// DUE and DURATION are mutually exclusive. The provider's Validating
// processor evaluates the *merged* row (supplied values over the stored
// ones), so writing DUE onto a task that already carries a DURATION throws
// "Only one of DUE or DURATION must be supplied." Clear it alongside.
put(Tasks.DURATION, null)
put(Tasks.PARENT_ID, form.parentId) put(Tasks.PARENT_ID, form.parentId)
// The provider treats a null tz as local time; set it explicitly for // The provider treats a null tz as local time; set it explicitly for
// timed tasks so the stored instant is unambiguous across zones. // timed tasks so the stored instant is unambiguous across zones.
@@ -69,16 +48,6 @@ object TaskWriteMapper {
put(Tasks.TZ, if (timed) tzId else null) put(Tasks.TZ, if (timed) tzId else null)
} }
/**
* Values for an update through the *instances* URI (a recurring occurrence).
* The provider clones the row into an override and strips list/recurrence
* fields as it goes, so LIST_ID and PARENT_ID are dropped here rather than
* written and silently ignored — moving one occurrence between lists or
* parents isn't a thing the override model expresses.
*/
fun instanceValues(form: TaskForm, tzId: String): Map<String, Any?> =
taskValues(form, tzId) - Tasks.LIST_ID - Tasks.PARENT_ID
fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> = fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> =
if (completed) { if (completed) {
mapOf( mapOf(
@@ -94,19 +63,6 @@ object TaskWriteMapper {
) )
} }
/**
* A reminder for [taskId], as an Alarm property row. The provider's validator
* requires MINUTES_BEFORE, REFERENCE (non-negative) and ALARM_TYPE on every
* write, so all three are always present.
*/
fun alarmValues(taskId: Long, minutesBeforeDue: Int): Map<String, Any?> = mapOf(
Properties.TASK_ID to taskId,
Properties.MIMETYPE to Alarm.MIMETYPE,
Alarm.MINUTES_BEFORE to minutesBeforeDue,
Alarm.REFERENCE to Alarm.REFERENCE_DUE,
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
)
fun localListValues(name: String, color: Int): Map<String, Any?> = mapOf( fun localListValues(name: String, color: Int): Map<String, Any?> = mapOf(
Lists.NAME to name.trim(), Lists.NAME to name.trim(),
Lists.COLOR to color, Lists.COLOR to color,

View File

@@ -66,9 +66,6 @@ object TasksContract {
const val IS_ALLDAY = "is_allday" const val IS_ALLDAY = "is_allday"
const val TZ = "tz" const val TZ = "tz"
const val RRULE = "rrule" const val RRULE = "rrule"
const val RDATE = "rdate"
/** Set on an override row — the master occurrence this one replaces. */
const val ORIGINAL_INSTANCE_ID = "original_instance_id"
const val PARENT_ID = "parent_id" const val PARENT_ID = "parent_id"
const val SORTING = "sorting" const val SORTING = "sorting"
const val CREATED = "created" const val CREATED = "created"
@@ -101,50 +98,6 @@ object TasksContract {
const val IS_RECURRING = "is_recurring" const val IS_RECURRING = "is_recurring"
} }
/** The `properties` table — per-task side rows, discriminated by [Properties.MIMETYPE]. */
object Properties {
const val PATH = "properties"
const val PROPERTY_ID = "property_id"
const val TASK_ID = "task_id"
const val MIMETYPE = "mimetype"
}
/**
* An alarm property row — a per-task reminder lead.
*
* Storage and sync format *only*: the provider fires nothing (its alarm
* scheduling is commented out and the internal `alarms` table is never
* populated), so [de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler]
* still arms the real AlarmManager alarm. Writing it here is what makes the
* lead survive a sync and show up in other OpenTasks clients.
*
* The columns are the generic `dataN` slots; the meanings below are the
* Alarm property's contract for them.
*/
object Alarm {
const val MIMETYPE = "vnd.android.cursor.item/alarm"
/** `data0` — minutes from the reference date; positive means *before* it. */
const val MINUTES_BEFORE = "data0"
/** `data1` — which date to count from. */
const val REFERENCE = "data1"
/** `data2` — optional message shown with the alarm. */
const val MESSAGE = "data2"
/** `data3` — alarm kind. Must be present, and non-zero to count as an alarm. */
const val ALARM_TYPE = "data3"
const val REFERENCE_DUE = 1
const val REFERENCE_START = 2
/** 0 (NOTHING) is excluded from the provider's `has_alarms` count — use MESSAGE. */
const val TYPE_MESSAGE = 1
}
fun propertiesUri(authority: String): Uri = Uri.parse("content://$authority/${Properties.PATH}")
// --- status values (TaskColumns.STATUS_*) -------------------------------- // --- status values (TaskColumns.STATUS_*) --------------------------------
const val STATUS_NEEDS_ACTION = 0 const val STATUS_NEEDS_ACTION = 0
const val STATUS_IN_PROCESS = 1 const val STATUS_IN_PROCESS = 1
@@ -159,15 +112,6 @@ object TasksContract {
fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}") fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}") fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}")
/**
* A single occurrence. Updating through this URI is how a *recurring* task is
* edited: the provider clones the row into an override task
* (`original_instance_id` set, recurrence fields stripped) instead of moving
* the series anchor, which is what writing to `tasks/<id>` would do.
*/
fun instanceUri(authority: String, instanceId: Long): Uri =
Uri.parse("content://$authority/${Instances.PATH}/$instanceId")
/** Append the sync-adapter params required to write local-account rows. */ /** Append the sync-adapter params required to write local-account rows. */
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri = fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
uri.buildUpon() uri.buildUpon()

View File

@@ -23,25 +23,6 @@ interface TasksDataSource {
fun insertTask(form: TaskForm): Long fun insertTask(form: TaskForm): Long
fun updateTask(taskId: Long, form: TaskForm) fun updateTask(taskId: Long, form: TaskForm)
/**
* Update a single occurrence of a recurring task, addressed by its *instance*
* row id. The provider forks an override task rather than moving the series
* anchor — which is what [updateTask] would do, since a recurring task's
* start/due are read from the instances view.
*/
fun updateInstance(instanceId: Long, form: TaskForm)
/**
* Set (or clear, with `null`) the per-task reminder lead, stored as an Alarm
* property row. The provider never fires it — [de.jeanlucmakiola.agendula
* .data.reminders.ReminderScheduler] does — but persisting it here is what
* syncs the lead and shares it with other OpenTasks clients.
*/
fun setAlarm(taskId: Long, minutesBeforeDue: Int?)
/** Every task's reminder lead, by task id. One query, for the scheduler. */
fun alarms(): Map<Long, Int>
fun setCompleted(taskId: Long, completed: Boolean) fun setCompleted(taskId: Long, completed: Boolean)
fun deleteTask(taskId: Long) fun deleteTask(taskId: Long)
fun createLocalList(name: String, color: Int): Long fun createLocalList(name: String, color: Int): Long

View File

@@ -39,13 +39,6 @@ interface TasksRepository {
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null) suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
suspend fun setCompleted(taskId: Long, completed: Boolean) suspend fun setCompleted(taskId: Long, completed: Boolean)
suspend fun deleteTask(taskId: Long) suspend fun deleteTask(taskId: Long)
/**
* The per-task reminder lead in minutes before due, or `null` if the task has
* none (in which case the list's / global setting applies). Read when the edit
* form loads so saving can't silently drop it.
*/
suspend fun reminderFor(taskId: Long): Int?
suspend fun createLocalList(name: String, color: Int): Long suspend fun createLocalList(name: String, color: Int): Long
/** Synchronous snapshot for the permission/onboarding gate. */ /** Synchronous snapshot for the permission/onboarding gate. */

View File

@@ -80,40 +80,19 @@ class TasksRepositoryImpl @Inject constructor(
} }
override suspend fun createTask(form: TaskForm): Long = override suspend fun createTask(form: TaskForm): Long =
withContext(io) { withContext(io) { dataSource.insertTask(form) }
val id = dataSource.insertTask(form)
form.reminderMinutesBeforeDue?.let { dataSource.setAlarm(id, it) }
id
}
override suspend fun reminderFor(taskId: Long): Int? =
withContext(io) { runCatching { dataSource.alarms()[taskId] }.getOrNull() }
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) = override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
withContext(io) { withContext(io) {
// Re-read just before writing: it settles the conflict check *and* tells // Conflict-safe overwrite: re-read just before writing and bail if the
// us which URI to write through. // provider's last_modified moved since the form captured it (external
val current = dataSource.task(taskId) // sync / another app). A null baseline means "force / overwrite anyway".
// Conflict-safe overwrite: bail if the provider's last_modified moved
// since the form captured it (external sync / another app). A null
// baseline means "force / overwrite anyway".
if (expectedLastModified != null) { if (expectedLastModified != null) {
val seen = current?.lastModified val current = dataSource.task(taskId)?.lastModified
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId) if (current != null && current != expectedLastModified) throw TaskConflictException(taskId)
} }
// Write the reminder first: forking a recurring occurrence copies the
// task's properties onto the new override row, so setting the alarm
// beforehand is what carries it across.
dataSource.setAlarm(taskId, form.reminderMinutesBeforeDue)
// A recurring task's start/due come from the instances view, so writing
// them back to tasks/<id> would re-anchor the whole series. Going through
// the occurrence lets the provider fork an override instead.
if (current != null && current.isRecurring) {
dataSource.updateInstance(current.id, form)
} else {
dataSource.updateTask(taskId, form) dataSource.updateTask(taskId, form)
} }
}
override suspend fun setCompleted(taskId: Long, completed: Boolean) = override suspend fun setCompleted(taskId: Long, completed: Boolean) =
withContext(io) { dataSource.setCompleted(taskId, completed) } withContext(io) { dataSource.setCompleted(taskId, completed) }

View File

@@ -1,42 +0,0 @@
package de.jeanlucmakiola.agendula.domain
import java.time.ZoneId
import java.time.ZoneOffset
import kotlin.time.Instant
/**
* All-day tasks are date-only in iCalendar. OpenTasks reads them back through
* `DateTime.toAllDay()`, which discards the time-of-day and resolves the
* remaining date against UTC — so the storage convention is **UTC midnight of
* the intended calendar date, with a null timezone**. Timed tasks, by contrast,
* are ordinary instants rendered in the device's zone.
*
* These two conventions disagree about which day a given instant is, which is
* why every all-day value needs an explicit conversion rather than a raw
* `Instant` passed straight through.
*/
/** UTC midnight of [date] — the storage form for an all-day value. */
fun allDayInstantOf(date: java.time.LocalDate): Instant =
Instant.fromEpochMilliseconds(date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli())
/**
* The calendar date this instant denotes: read in UTC for [allDay] values,
* in [zone] for timed ones.
*/
fun Instant.calendarDate(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): java.time.LocalDate =
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
.atZone(if (allDay) ZoneOffset.UTC else zone)
.toLocalDate()
/**
* Move an instant across the two conventions when the all-day switch flips, so
* the day the user is looking at stays put. Without this, toggling all-day off
* turns a UTC-midnight value into "02:00" in Berlin (or the previous day, 19:00,
* in New York) — reading to the user as "the time reset itself".
*/
fun Instant.rebasedForAllDay(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): Instant =
if (allDay) allDayInstantOf(calendarDate(allDay = false, zone = zone))
else Instant.fromEpochMilliseconds(
calendarDate(allDay = true).atStartOfDay(zone).toInstant().toEpochMilli(),
)

View File

@@ -48,12 +48,6 @@ data class Task(
val listName: String?, val listName: String?,
val accountName: String?, val accountName: String?,
val parentId: Long?, val parentId: Long?,
/**
* This row carries a recurrence rule, so [id] is one occurrence of a series
* and [start]/[due] are that occurrence's resolved times — *not* the master's
* anchor. Edits must go through the instances URI (see
* [de.jeanlucmakiola.agendula.data.tasks.TasksContract.instanceUri]).
*/
val isRecurring: Boolean, val isRecurring: Boolean,
val distanceFromCurrent: Int?, val distanceFromCurrent: Int?,
val created: Instant?, val created: Instant?,

View File

@@ -11,16 +11,12 @@ import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
@@ -43,18 +39,6 @@ fun RootScreen(
ActivityResultContracts.RequestMultiplePermissions(), ActivityResultContracts.RequestMultiplePermissions(),
) { permissionViewModel.refresh() } ) { permissionViewModel.refresh() }
// Re-check on every resume, not just after the in-app request: the user may
// have granted the permission (or installed a provider) in system Settings and
// come back, and otherwise the gate would hold until the process restarts.
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) permissionViewModel.refresh()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
when (permission.status) { when (permission.status) {
ProviderStatus.NO_PROVIDER -> Gate( ProviderStatus.NO_PROVIDER -> Gate(
modifier = modifier, modifier = modifier,

View File

@@ -0,0 +1,167 @@
package de.jeanlucmakiola.agendula.ui.common
import de.jeanlucmakiola.floret.time.formatDateTime
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.Event
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import de.jeanlucmakiola.agendula.R
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import kotlin.time.Instant
private val zone: ZoneId get() = ZoneId.systemDefault()
internal fun Instant.toLocalDate(): LocalDate =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
internal fun Instant.toLocalTime(): LocalTime =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime()
internal fun localToInstant(date: LocalDate, time: LocalTime): Instant =
Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli())
/**
* A labelled date(-time) field for the edit form: a tonal row showing the
* current value (or nothing), tappable to pick a date and — unless [allDay] —
* a time. A clear affordance appears once a value is set. Emits `null` when
* cleared. Styled to match the app's rounded tonal family.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateTimeField(
label: String,
value: Instant?,
allDay: Boolean,
onChange: (Instant?) -> Unit,
modifier: Modifier = Modifier,
) {
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
var pendingDate by remember { mutableStateOf<LocalDate?>(null) }
Surface(
onClick = { showDatePicker = true },
shape = RoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Rounded.Event, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value?.formatDateTime(allDay) ?: stringResource(R.string.edit_set),
style = MaterialTheme.typography.bodyLarge,
)
}
if (value != null) {
IconButton(onClick = { onChange(null) }) {
Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear))
}
}
}
}
if (showDatePicker) {
val initialMillis = (value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis()))
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(
onClick = {
showDatePicker = false
val millis = dateState.selectedDateMillis ?: return@TextButton
val date = java.time.Instant.ofEpochMilli(millis)
.atZone(ZoneOffset.UTC).toLocalDate()
if (allDay) {
onChange(localToInstant(date, LocalTime.MIDNIGHT))
} else {
pendingDate = date
showTimePicker = true
}
},
) { Text(stringResource(android.R.string.ok)) }
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) {
Text(stringResource(android.R.string.cancel))
}
},
) { DatePicker(state = dateState) }
}
if (showTimePicker) {
val base = value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis())
val timeState = rememberTimePickerState(
initialHour = base.toLocalTime().hour,
initialMinute = base.toLocalTime().minute,
)
Dialog(onDismissRequest = { showTimePicker = false }) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
TimePicker(state = timeState)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = { showTimePicker = false }) {
Text(stringResource(android.R.string.cancel))
}
TextButton(onClick = {
showTimePicker = false
val date = pendingDate ?: return@TextButton
onChange(localToInstant(date, LocalTime.of(timeState.hour, timeState.minute)))
}) { Text(stringResource(android.R.string.ok)) }
}
}
}
}
}
}

View File

@@ -1,20 +0,0 @@
package de.jeanlucmakiola.agendula.ui.common
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import kotlin.time.Instant
/**
* Zone helpers shared by the date/time pickers. All-day conversions live in
* [de.jeanlucmakiola.agendula.domain.AllDayTime] — these cover the timed case,
* where the device zone is the right frame of reference.
*/
private val zone: ZoneId get() = ZoneId.systemDefault()
internal fun Instant.toLocalTime(): LocalTime =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime()
internal fun localToInstant(date: LocalDate, time: LocalTime): Instant =
Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli())

View File

@@ -510,7 +510,8 @@ private fun taskWhenLines(task: Task): Pair<String, String?>? {
primary to secondary primary to secondary
} }
due != null -> due.formatDate(task.isAllDay) 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(task.isAllDay) 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

@@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
@@ -12,6 +11,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -42,7 +42,7 @@ class TaskDetailViewModel @Inject constructor(
if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail) if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail)
} }
.onStart { emit(TaskDetailUiState.Loading) } .onStart { emit(TaskDetailUiState.Loading) }
.recoveringFromProviderFailure { TaskDetailUiState.NotFound } .catch { emit(TaskDetailUiState.NotFound) }
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading)

View File

@@ -99,8 +99,7 @@ import de.jeanlucmakiola.floret.time.formatTime
import de.jeanlucmakiola.agendula.ui.common.localToInstant import de.jeanlucmakiola.agendula.ui.common.localToInstant
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.agendula.domain.allDayInstantOf import de.jeanlucmakiola.agendula.ui.common.toLocalDate
import de.jeanlucmakiola.agendula.domain.calendarDate
import de.jeanlucmakiola.agendula.ui.common.toLocalTime import de.jeanlucmakiola.agendula.ui.common.toLocalTime
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
import java.time.LocalTime import java.time.LocalTime
@@ -179,7 +178,7 @@ private fun EditContent(
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
val gap = 12.dp val gap = 12.dp
var pickerTarget by rememberSaveable { mutableStateOf<PickerTarget?>(null) } var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
var showListPicker by rememberSaveable { mutableStateOf(false) } var showListPicker by rememberSaveable { mutableStateOf(false) }
var showParentPicker by rememberSaveable { mutableStateOf(false) } var showParentPicker by rememberSaveable { mutableStateOf(false) }
var showReminderPicker by rememberSaveable { mutableStateOf(false) } var showReminderPicker by rememberSaveable { mutableStateOf(false) }
@@ -690,15 +689,12 @@ private fun DateTimePickerFlow(
onResult: (Instant) -> Unit, onResult: (Instant) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
var pendingDate by rememberSaveable { mutableStateOf<java.time.LocalDate?>(null) } var pendingDate by remember { mutableStateOf<java.time.LocalDate?>(null) }
var showTime by rememberSaveable { mutableStateOf(false) } var showTime by remember { mutableStateOf(false) }
if (!showTime) { if (!showTime) {
// M3's DatePicker speaks UTC millis. An all-day value is already UTC-based,
// a timed one is read in the device zone — calendarDate picks the right frame
// so the dialog opens on the day the rest of the UI shows.
val initialMillis = (initial ?: nowInstant()) val initialMillis = (initial ?: nowInstant())
.calendarDate(allDay).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() .toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis) val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
DatePickerDialog( DatePickerDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -707,7 +703,7 @@ private fun DateTimePickerFlow(
val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton } val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton }
val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate() val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
if (allDay) { if (allDay) {
onResult(allDayInstantOf(date)) onResult(localToInstant(date, LocalTime.MIDNIGHT))
} else { } else {
pendingDate = date pendingDate = date
showTime = true showTime = true

View File

@@ -15,7 +15,6 @@ import de.jeanlucmakiola.agendula.domain.TaskFormError
import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.populatedFields import de.jeanlucmakiola.agendula.domain.populatedFields
import de.jeanlucmakiola.agendula.domain.rebasedForAllDay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -71,15 +70,6 @@ class TaskEditViewModel @Inject constructor(
private var editingTaskId: Long? = null private var editingTaskId: Long? = null
/**
* Whether the form has already been populated. The host `LaunchedEffect`
* re-fires whenever the composition restarts — an Activity recreation
* (rotation, theme/font/display-size change, split-screen, unfolding) — while
* this ViewModel survives on the nav back stack. Without this guard the
* rebind would overwrite in-progress edits with the untouched provider row.
*/
private var bound = false
/** `last_modified` captured when the form loaded — the conflict-check baseline. */ /** `last_modified` captured when the form loaded — the conflict-check baseline. */
private var baselineLastModified: Instant? = null private var baselineLastModified: Instant? = null
@@ -88,8 +78,6 @@ class TaskEditViewModel @Inject constructor(
/** Start a fresh task, optionally pre-selecting a list / parent. */ /** Start a fresh task, optionally pre-selecting a list / parent. */
fun bindNew(presetListId: Long? = null, parentId: Long? = null) { fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
if (bound) return
bound = true
editingTaskId = null editingTaskId = null
baselineLastModified = null baselineLastModified = null
viewModelScope.launch { viewModelScope.launch {
@@ -115,8 +103,6 @@ class TaskEditViewModel @Inject constructor(
/** Load an existing task for editing. */ /** Load an existing task for editing. */
fun bindEdit(taskId: Long) { fun bindEdit(taskId: Long) {
if (bound && editingTaskId == taskId) return
bound = true
editingTaskId = taskId editingTaskId = taskId
viewModelScope.launch { viewModelScope.launch {
defaultFields = settingsPrefs.settings.first().defaultEditFields defaultFields = settingsPrefs.settings.first().defaultEditFields
@@ -140,7 +126,6 @@ class TaskEditViewModel @Inject constructor(
priority = task.priority, priority = task.priority,
parentId = task.parentId, parentId = task.parentId,
percentComplete = task.percentComplete, percentComplete = task.percentComplete,
reminderMinutesBeforeDue = repository.reminderFor(taskId),
lists = lists, lists = lists,
parentCandidates = loadParents(task.listId, selfId = taskId), parentCandidates = loadParents(task.listId, selfId = taskId),
), ),
@@ -193,19 +178,7 @@ class TaskEditViewModel @Inject constructor(
fun onStartChange(value: Instant?) = update { it.copy(start = value) } fun onStartChange(value: Instant?) = update { it.copy(start = value) }
fun onDueChange(value: Instant?) = update { it.copy(due = value) } fun onDueChange(value: Instant?) = update { it.copy(due = value) }
/** fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) }
* All-day and timed values use different conventions (UTC midnight vs. a real
* instant in the device zone), so the switch has to move the timestamps too —
* flipping the flag alone makes an all-day task read back as "02:00", which
* looks to the user like the time reset itself.
*/
fun onAllDayChange(value: Boolean) = update {
it.copy(
isAllDay = value,
start = it.start?.rebasedForAllDay(value),
due = it.due?.rebasedForAllDay(value),
)
}
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) } fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) } fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) } fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }

View File

@@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.floret.time.DayWindow import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
@@ -13,6 +12,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import java.time.ZoneId import java.time.ZoneId
@@ -56,7 +56,7 @@ class ListsViewModel @Inject constructor(
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)), repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
) { lists, openTasks, completedTasks -> ) { lists, openTasks, completedTasks ->
buildContent(lists, openTasks, completedTasks) as ListsUiState buildContent(lists, openTasks, completedTasks) as ListsUiState
}.recoveringFromProviderFailure { ListsUiState.Failure } }.catch { emit(ListsUiState.Failure) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
private fun buildContent( private fun buildContent(

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

@@ -7,12 +7,12 @@ import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverride
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -33,14 +33,8 @@ class SettingsViewModel @Inject constructor(
repository: TasksRepository, repository: TasksRepository,
) : ViewModel() { ) : ViewModel() {
// MainActivity collects this for the theme, above the permission gate and for
// the whole Activity lifetime — so the list flow must survive the pre-grant
// SecurityException and recover once permission is given, not die for good.
val state: StateFlow<SettingsUiState> = val state: StateFlow<SettingsUiState> =
combine( combine(prefs.settings, repository.taskLists().catch { emit(emptyList()) }) { settings, lists ->
prefs.settings,
repository.taskLists().recoveringFromProviderFailure { emptyList() },
) { settings, lists ->
SettingsUiState(settings, lists) SettingsUiState(settings, lists)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())

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

@@ -5,7 +5,6 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
@@ -13,6 +12,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
@@ -87,10 +87,7 @@ class TaskListViewModel @Inject constructor(
} }
} }
.onStart { emit(TaskListUiState.Loading) } .onStart { emit(TaskListUiState.Loading) }
// Recover rather than terminate: a provider hiccup (mid-update, .catch { emit(TaskListUiState.Failure) }
// permission not yet granted) shows Failure but keeps retrying,
// so the screen heals itself instead of staying stuck.
.recoveringFromProviderFailure { TaskListUiState.Failure }
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
@@ -115,9 +112,6 @@ class TaskListViewModel @Inject constructor(
combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() } combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() }
} }
} }
// Without this an exception here escapes stateIn's coroutine, past
// viewModelScope's SupervisorJob, and crashes the process.
.recoveringFromProviderFailure { emptyMap() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
/** The screen reports which expanded parents need their children fetched. */ /** The screen reports which expanded parents need their children fetched. */

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

@@ -50,31 +50,6 @@ class TaskMapperTest {
assertThat(task.isSubtask).isTrue() assertThat(task.isSubtask).isTrue()
} }
@Test
fun `recurrence is detected from rrule when is_recurring is absent`() {
// tasks.org's bundled provider is DB 22 and has no `is_recurring` column;
// reading it alone would report the series as one-off and send its edits
// to the master row, re-anchoring the whole thing.
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RRULE to "FREQ=WEEKLY;BYDAY=MO")),
)
assertThat(task.isRecurring).isTrue()
}
@Test
fun `recurrence is detected from rdate alone`() {
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RDATE to "20260720T090000Z")),
)
assertThat(task.isRecurring).isTrue()
}
@Test
fun `a plain task is not recurring`() {
val task = TaskMapper.task(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.TITLE to "One-off")))
assertThat(task.isRecurring).isFalse()
}
@Test @Test
fun `falls back to instance id when task_id missing, and list color when no task color`() { fun `falls back to instance id when task_id missing, and list color when no task color`() {
val task = TaskMapper.task( val task = TaskMapper.task(

View File

@@ -85,57 +85,6 @@ class TaskWriteMapperTest {
assertThat(values[Tasks.TZ]).isNull() assertThat(values[Tasks.TZ]).isNull()
} }
@Test
fun `all-day timestamps are pinned to UTC midnight`() {
// 2026-07-20T22:00Z — i.e. local midnight on the 21st in Berlin (UTC+2).
// The provider resolves all-day dates against UTC, so storing this as-is
// would land the task on the 20th for anyone reading it back.
val berlinMidnight = Instant.fromEpochMilliseconds(1_784_412_000_000L)
val values = TaskWriteMapper.taskValues(
TaskForm(title = "Holiday", listId = 1L, start = berlinMidnight, due = berlinMidnight, isAllDay = true),
tzId = "Europe/Berlin",
)
val dayMs = 24L * 60 * 60 * 1000
assertThat(values[Tasks.DUE] as Long % dayMs).isEqualTo(0L)
assertThat(values[Tasks.DTSTART] as Long % dayMs).isEqualTo(0L)
}
@Test
fun `timed timestamps are written untouched`() {
val at = Instant.fromEpochMilliseconds(1_784_412_345_678L)
val values = TaskWriteMapper.taskValues(
TaskForm(title = "Standup", listId = 1L, start = at, due = at),
tzId = "Europe/Berlin",
)
assertThat(values[Tasks.DTSTART]).isEqualTo(1_784_412_345_678L)
assertThat(values[Tasks.DUE]).isEqualTo(1_784_412_345_678L)
}
@Test
fun `duration is always cleared so it cannot collide with due`() {
// The provider validates the *merged* row and throws "Only one of DUE or
// DURATION must be supplied" if the stored row still carries a duration.
val values = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, due = Instant.fromEpochMilliseconds(5_000L)),
tzId = "UTC",
)
assertThat(values.containsKey(Tasks.DURATION)).isTrue()
assertThat(values[Tasks.DURATION]).isNull()
}
@Test
fun `instance values drop list and parent, which an override cannot express`() {
val form = TaskForm(title = "x", listId = 4L, parentId = 7L, due = Instant.fromEpochMilliseconds(1_000L))
val values = TaskWriteMapper.instanceValues(form, tzId = "UTC")
assertThat(values.containsKey(Tasks.LIST_ID)).isFalse()
assertThat(values.containsKey(Tasks.PARENT_ID)).isFalse()
// …but still carries the edit itself.
assertThat(values[Tasks.TITLE]).isEqualTo("x")
assertThat(values[Tasks.DUE]).isEqualTo(1_000L)
}
@Test @Test
fun `completion sets status, percent and timestamp, un-completion clears them`() { fun `completion sets status, percent and timestamp, un-completion clears them`() {
val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L) val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L)
@@ -148,22 +97,6 @@ class TaskWriteMapperTest {
assertThat(undone[Tasks.COMPLETED]).isNull() assertThat(undone[Tasks.COMPLETED]).isNull()
} }
@Test
fun `alarm carries every column the provider's validator demands`() {
val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30)
assertThat(values[TasksContract.Properties.TASK_ID]).isEqualTo(12L)
assertThat(values[TasksContract.Properties.MIMETYPE])
.isEqualTo("vnd.android.cursor.item/alarm")
assertThat(values[TasksContract.Alarm.MINUTES_BEFORE]).isEqualTo(30)
// REFERENCE must be present and non-negative, ALARM_TYPE present and
// non-zero (0 is excluded from the provider's has_alarms count).
assertThat(values[TasksContract.Alarm.REFERENCE]).isEqualTo(TasksContract.Alarm.REFERENCE_DUE)
assertThat(values[TasksContract.Alarm.ALARM_TYPE]).isEqualTo(TasksContract.Alarm.TYPE_MESSAGE)
// property_id must be absent or the insert is rejected.
assertThat(values.containsKey(TasksContract.Properties.PROPERTY_ID)).isFalse()
}
@Test @Test
fun `local list uses the LOCAL account`() { fun `local list uses the LOCAL account`() {
val values = TaskWriteMapper.localListValues("Inbox", 0x123) val values = TaskWriteMapper.localListValues("Inbox", 0x123)

View File

@@ -1,60 +0,0 @@
package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.ZoneId
import kotlin.time.Instant
class AllDayTimeTest {
private val berlin = ZoneId.of("Europe/Berlin") // UTC+2 in July
private val newYork = ZoneId.of("America/New_York") // UTC-4 in July
private val julyTwentieth = LocalDate.of(2026, 7, 20)
@Test
fun `an all-day instant is UTC midnight of its date`() {
val instant = allDayInstantOf(julyTwentieth)
assertThat(instant.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0L)
assertThat(instant.calendarDate(allDay = true)).isEqualTo(julyTwentieth)
}
@Test
fun `an all-day date reads the same everywhere, unlike a timed one`() {
val allDay = allDayInstantOf(julyTwentieth)
// The whole point: zone must not change which day an all-day value denotes.
assertThat(allDay.calendarDate(allDay = true, zone = berlin)).isEqualTo(julyTwentieth)
assertThat(allDay.calendarDate(allDay = true, zone = newYork)).isEqualTo(julyTwentieth)
// Read as a timed value in New York it would slip to the 19th — the bug.
assertThat(allDay.calendarDate(allDay = false, zone = newYork)).isEqualTo(julyTwentieth.minusDays(1))
}
@Test
fun `toggling all-day off keeps the day and lands on local midnight`() {
val allDay = allDayInstantOf(julyTwentieth)
val timed = allDay.rebasedForAllDay(allDay = false, zone = berlin)
assertThat(timed.calendarDate(allDay = false, zone = berlin)).isEqualTo(julyTwentieth)
val local = java.time.Instant.ofEpochMilli(timed.toEpochMilliseconds()).atZone(berlin)
assertThat(local.toLocalTime()).isEqualTo(java.time.LocalTime.MIDNIGHT)
}
@Test
fun `toggling all-day on keeps the day the user was looking at`() {
// 2026-07-20T23:30 in Berlin — late enough that a naive UTC read slips a day.
val lateEvening = Instant.fromEpochMilliseconds(
julyTwentieth.atTime(23, 30).atZone(berlin).toInstant().toEpochMilli(),
)
val allDay = lateEvening.rebasedForAllDay(allDay = true, zone = berlin)
assertThat(allDay.calendarDate(allDay = true)).isEqualTo(julyTwentieth)
}
@Test
fun `round-tripping the toggle is stable`() {
val original = allDayInstantOf(julyTwentieth)
val there = original.rebasedForAllDay(allDay = false, zone = newYork)
val back = there.rebasedForAllDay(allDay = true, zone = newYork)
assertThat(back).isEqualTo(original)
}
}

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())