Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c47653c9cb | ||
| e639e250b7 | |||
|
|
58a50512bf | ||
|
|
1add1fcadb | ||
|
|
217d5d7afd | ||
| 26628dc0bb | |||
| d2e3832ef2 | |||
| 93857135b3 | |||
| 36beb2d0ad | |||
| bc70ed3a9f | |||
| 3f166ef5f0 | |||
| 41bd49826a | |||
| 05c75bafa7 | |||
| 976d496d21 | |||
| 245f1db536 | |||
| 623e533547 | |||
| 2e50356f81 | |||
| e6f503c02a | |||
| c53511196d | |||
| a8595e26b4 | |||
| 411e27659f |
24
.forgejo/ISSUE_TEMPLATE/config.yml
Normal file
24
.forgejo/ISSUE_TEMPLATE/config.yml
Normal 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.
|
||||||
42
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal file
42
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal 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
|
||||||
@@ -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.
|
||||||
39
.forgejo/workflows/translations.yaml
Normal file
39
.forgejo/workflows/translations.yaml
Normal 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
|
||||||
@@ -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,17 +395,21 @@ 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'
|
||||||
continue-on-error: true
|
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
|
||||||
|
# 0.3.0, 0.3.1 and 0.3.2 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 must fail the release loudly.
|
||||||
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
|
||||||
@@ -399,13 +438,18 @@ 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
|
||||||
# The pipeline creates the tag via the Gitea API, which the push mirror
|
# Push the tag to Codeberg ourselves. Under Codeberg-canonical the
|
||||||
# (sync_on_commit only fires on real git pushes) doesn't propagate
|
# mirror runs Codeberg -> Gitea, so waiting for a tag to arrive here
|
||||||
# promptly — so a release POST that carries a target_commitish can
|
# from Gitea (what 0.3.2 did) would wait forever. The tag this
|
||||||
# outrun the mirror and 500 on a commit/tag Codeberg hasn't received.
|
# pipeline minted on Gitea is in fact *deleted* by the next mirror
|
||||||
# Push the tag straight to Codeberg so it's guaranteed present, then
|
# sync until Codeberg has it — so pushing it here is what makes it
|
||||||
# attach the release to that existing tag with NO target_commitish
|
# durable on both forges.
|
||||||
# (which is what triggered the 500).
|
#
|
||||||
|
# Pushing the ref first and attaching with NO target_commitish is
|
||||||
|
# deliberate: a release POST carrying a target_commitish for a commit
|
||||||
|
# or tag Codeberg hasn't received yet is what produced the
|
||||||
|
# empty-bodied 500s. Attaching to a ref that already exists doesn't
|
||||||
|
# need the API to write one.
|
||||||
git tag -f "$TAG" "$SHA"
|
git tag -f "$TAG" "$SHA"
|
||||||
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/agendula.git" \
|
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/agendula.git" \
|
||||||
"refs/tags/$TAG"
|
"refs/tags/$TAG"
|
||||||
@@ -421,19 +465,31 @@ jobs:
|
|||||||
"prerelease": pre == "true",
|
"prerelease": pre == "true",
|
||||||
}))
|
}))
|
||||||
PY
|
PY
|
||||||
# Upsert (re-run safe).
|
# Create (or update) the release. Codeberg 500s on a POST/GET against a
|
||||||
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
# tag it has only just received — the release request outruns the
|
||||||
if [ -n "$ID" ]; then
|
# indexing of the ref we pushed a moment ago — so a single attempt can
|
||||||
|
# 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
|
||||||
|
|||||||
61
.gitea/workflows/renovate.yml
Normal file
61
.gitea/workflows/renovate.yml
Normal 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
10
.gitignore
vendored
@@ -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
2
.gitmodules
vendored
@@ -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
|
||||||
|
|||||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -7,6 +7,26 @@ 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
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Releases reach the Codeberg download channel again. 0.3.1 published to
|
||||||
|
F-Droid but never appeared on Codeberg, so if you install from there — or
|
||||||
|
through Obtainium — this is the release that finally carries 0.3.0's
|
||||||
|
launch-crash fix. The app itself is unchanged from 0.3.1.
|
||||||
|
|
||||||
## [0.3.1] - 2026-07-20
|
## [0.3.1] - 2026-07-20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -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).
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ android {
|
|||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
|
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
|
||||||
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
|
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
|
||||||
versionCode = 301
|
versionCode = 302
|
||||||
versionName = "0.3.1"
|
versionName = "0.3.2"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -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() }
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFormField
|
|||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||||
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.reminderLeadFor
|
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -39,15 +39,17 @@ data class Settings(
|
|||||||
val bottomAddBar: Boolean = false,
|
val bottomAddBar: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* Per-list overrides of [reminderLeadMinutes]: a list present in the map
|
* Per-list overrides of [reminderLeadMinutes]: a list present in the map
|
||||||
* overrides the global default (a null value = no reminder); absent = inherit.
|
* overrides the global default (an empty list = no reminder); absent =
|
||||||
|
* inherit. Agendula offers a single reminder, so each override is a
|
||||||
|
* one-element (or empty) list.
|
||||||
*/
|
*/
|
||||||
val perListReminderOverride: Map<Long, Int?> = emptyMap(),
|
val perListReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||||
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
||||||
val defaultEditFields: Set<TaskFormField> = emptySet(),
|
val defaultEditFields: Set<TaskFormField> = emptySet(),
|
||||||
) {
|
) {
|
||||||
/** The lead time for a task in [listId]: its override if set, else the global default. */
|
/** The lead time for a task in [listId]: its override if set, else the global default. */
|
||||||
fun reminderLeadFor(listId: Long): Int? =
|
fun reminderLeadFor(listId: Long): Int? =
|
||||||
perListReminderOverride.reminderLeadFor(listId, reminderLeadMinutes)
|
perListReminderOverride.reminderLeadsFor(listId, listOf(reminderLeadMinutes)).firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ fun ReminderLeadPicker(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
presets: List<Int> = REMINDER_PRESETS,
|
presets: List<Int> = REMINDER_PRESETS,
|
||||||
) {
|
) {
|
||||||
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes
|
// Agendula is single-reminder: an override carries a one-element list, so
|
||||||
|
// take the single value for this single-select picker.
|
||||||
|
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes?.firstOrNull()
|
||||||
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||||
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
|
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
|
||||||
|
|
||||||
@@ -65,7 +67,7 @@ fun ReminderLeadPicker(
|
|||||||
val options = buildList {
|
val options = buildList {
|
||||||
if (allowInherit) add(ReminderOverride.Inherit)
|
if (allowInherit) add(ReminderOverride.Inherit)
|
||||||
if (allowNone) add(ReminderOverride.None)
|
if (allowNone) add(ReminderOverride.None)
|
||||||
presets.forEach { add(ReminderOverride.Minutes(it)) }
|
presets.forEach { add(ReminderOverride.Minutes(listOf(it))) }
|
||||||
}
|
}
|
||||||
val rowCount = options.size + 1 // + the custom row
|
val rowCount = options.size + 1 // + the custom row
|
||||||
|
|
||||||
@@ -105,7 +107,7 @@ fun ReminderLeadPicker(
|
|||||||
unit = unit,
|
unit = unit,
|
||||||
onUnitChange = { unit = it },
|
onUnitChange = { unit = it },
|
||||||
onConfirm = { minutes ->
|
onConfirm = { minutes ->
|
||||||
onSelect(ReminderOverride.Minutes(minutes))
|
onSelect(ReminderOverride.Minutes(listOf(minutes)))
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -168,5 +170,5 @@ private fun CustomReminderEditor(
|
|||||||
private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) {
|
private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) {
|
||||||
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes.first())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -501,13 +501,17 @@ private fun taskWhenLines(task: Task): Pair<String, String?>? {
|
|||||||
val due = task.due
|
val due = task.due
|
||||||
return when {
|
return when {
|
||||||
start != null && due != null -> {
|
start != null && due != null -> {
|
||||||
val sameDay = start.formatDate() == due.formatDate()
|
val allDay = task.isAllDay
|
||||||
val primary = if (sameDay) due.formatDate() else "${start.formatDate()} – ${due.formatDate()}"
|
val sameDay = start.formatDate(allDay) == due.formatDate(allDay)
|
||||||
val secondary = if (task.isAllDay) null else "${start.formatTime()} – ${due.formatTime()}"
|
val primary =
|
||||||
|
if (sameDay) due.formatDate(allDay)
|
||||||
|
else "${start.formatDate(allDay)} – ${due.formatDate(allDay)}"
|
||||||
|
val secondary = if (allDay) null else "${start.formatTime()} – ${due.formatTime()}"
|
||||||
primary to secondary
|
primary to secondary
|
||||||
}
|
}
|
||||||
due != null -> due.formatDate() to if (task.isAllDay) null else due.formatTime()
|
due != null -> due.formatDate(task.isAllDay) to if (task.isAllDay) null else due.formatTime()
|
||||||
start != null -> start.formatDate() to if (task.isAllDay) null else start.formatTime()
|
start != null ->
|
||||||
|
start.formatDate(task.isAllDay) to if (task.isAllDay) null else start.formatTime()
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -653,7 +653,7 @@ private fun ScheduleRow(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = value.formatDate(),
|
text = value.formatDate(allDay),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = valueColor,
|
color = valueColor,
|
||||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
||||||
@@ -865,7 +865,7 @@ private fun ParentPickerSheet(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = task.title.ifBlank { stringResource(R.string.task_untitled) },
|
title = task.title.ifBlank { stringResource(R.string.task_untitled) },
|
||||||
position = positionOf(index, section.tasks.size),
|
position = positionOf(index, section.tasks.size),
|
||||||
summary = task.due?.formatDate(),
|
summary = task.due?.formatDate(task.isAllDay),
|
||||||
selected = task.taskId == selectedId,
|
selected = task.taskId == selectedId,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
onClick = { choose(task.taskId) },
|
onClick = { choose(task.taskId) },
|
||||||
|
|||||||
@@ -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,8 +92,10 @@ 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.agendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
@@ -210,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) {
|
||||||
@@ -483,10 +536,10 @@ private fun RemindersScreen(
|
|||||||
if (showOffset) {
|
if (showOffset) {
|
||||||
ReminderLeadPicker(
|
ReminderLeadPicker(
|
||||||
title = stringResource(R.string.settings_default_reminder),
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
selected = ReminderOverride.Minutes(state.settings.reminderLeadMinutes),
|
selected = ReminderOverride.Minutes(listOf(state.settings.reminderLeadMinutes)),
|
||||||
allowInherit = false,
|
allowInherit = false,
|
||||||
allowNone = false,
|
allowNone = false,
|
||||||
onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes) },
|
onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes.first()) },
|
||||||
onDismiss = { showOffset = false },
|
onDismiss = { showOffset = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -504,14 +557,8 @@ private fun RemindersScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The stored override for [listId], as a picker choice (absent → inherit). */
|
/** The stored override for [listId], as a picker choice (absent → inherit). */
|
||||||
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride {
|
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride =
|
||||||
val map = state.settings.perListReminderOverride
|
state.settings.perListReminderOverride.reminderOverrideFor(listId)
|
||||||
return when {
|
|
||||||
!map.containsKey(listId) -> ReminderOverride.Inherit
|
|
||||||
map[listId] == null -> ReminderOverride.None
|
|
||||||
else -> ReminderOverride.Minutes(map.getValue(listId)!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Row summary for a list: its override, or the inherited global default. */
|
/** Row summary for a list: its override, or the inherited global default. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -519,7 +566,7 @@ private fun listOverrideSummary(choice: ReminderOverride, globalDefault: Int): S
|
|||||||
ReminderOverride.Inherit ->
|
ReminderOverride.Inherit ->
|
||||||
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
|
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
|
||||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes.first())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.1–0.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
357
docs/STORAGE-AND-SYNC.md
Normal 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 "2–4 days shippable, +1
|
||||||
|
week for F-Droid." Steps 2–5 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**.
|
||||||
6
fastlane/metadata/android/en-US/changelogs/302.txt
Normal file
6
fastlane/metadata/android/en-US/changelogs/302.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
### Fixed
|
||||||
|
- Releases reach the Codeberg download channel again. 0.3.1 published to
|
||||||
|
F-Droid but never appeared on Codeberg, so if you install from there — or
|
||||||
|
through Obtainium — this is the release that finally carries 0.3.0's
|
||||||
|
launch-crash fix. The app itself is unchanged from 0.3.1.
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
Submodule floret-kit updated: 566caf4305...e047a2bd48
122
renovate.json5
Normal file
122
renovate.json5
Normal 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
94
scripts/check_translations.py
Executable 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())
|
||||||
Reference in New Issue
Block a user