3 Commits
Author SHA1 Message Date
makiolaj f5052f3fa9 chore: 1.0.0-beta.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 11m15s
The first public build. CHANGELOG's Unreleased section is rewritten as
user-facing notes, since it becomes the beta's release notes. The README
gains an install section, and the store short description fits Play's
80 characters.
2026-10-06 19:14:11 +02:00
makiolaj e870918a03 docs: plain-language pass over every doc
Removes AI-writing patterns across the docs, store listing and
templates: dash-heavy rhythm, not-X-but-Y contrasts, one-line kickers
and decorative bold. Facts, code, links and headings are unchanged.

Also fixes leftovers copied from the sibling apps: the crash report
template promised "no calendar content", and CONTRIBUTING described
Agendula's task-provider layout, test seams and commit style.
2026-10-06 19:14:11 +02:00
makiolaj 881f6b1286 ci(release): port Agendula's 1.1.0 release pipeline, with betas
Brings the workflows, scripts and fastlane setup up to Agendula's
v1.1.0-beta.1 state:

- beta.yaml: a release/** branch carrying X.Y.Z-beta.N publishes a
  Gitea + Codeberg pre-release with the signed APK. Never F-Droid or Play.
- scripts/version_info.sh derives versionCode from versionName for every
  workflow and CI step. Clockula has no shipped 1.0.x codes, so it uses one
  scheme: X*1000000 + Y*10000 + Z*100 + (beta N, or 99 for stable).
- CI checks the committed versionCode, blocks a beta from reaching main,
  enforces the 500-char What's New and validates the store listing.
- Shared publish_gitea_release.sh / publish_codeberg_release.sh /
  release_notes.sh replace the inline steps in release.yaml.
- Renovate also runs on every push to main to rebase conflicted PRs.
- jniLibs keepDebugSymbols, which the reproducibility guard now requires.

Fixes a bug present in Agendula too: the Codeberg tag lookups used
/git/refs/tags/{tag}, which matches by prefix, so v1.0.0 would have
"existed" as soon as v1.0.0-beta.1 did. That would have refused every
later beta and skipped the stable release. Both now use /tags/{tag}.
2026-10-06 19:14:11 +02:00
32 changed files with 1993 additions and 1458 deletions
+7 -8
View File
@@ -6,8 +6,8 @@ contact_links:
- name: Translate Clockula
url: https://weblate.dev.jeanlucmakiola.de/engage/clockula/
about: >-
Translations are managed on Weblate, not here — it owns every values-*
file, so a hand-edited translation gets overwritten on the next sync.
Translations are managed on Weblate, which owns every values-* file, so
a translation edited by hand here gets overwritten on the next sync.
No coding needed: pick or request a language and translate in the browser.
- name: Contributing guide
@@ -20,13 +20,12 @@ contact_links:
url: https://codeberg.org/jlmakiola/clockula/src/branch/main/README.md
about: >-
Open the app's own self-check first (Settings → Why might my alarm not
ring?). It reports what your device is actually doing — battery
optimisation, notification and full-screen-intent permissions, the next
alarm as the system sees it — and that report is what makes such an issue
actionable.
ring?). It reports what your device is doing (battery optimisation,
notification and full-screen-intent permissions, the next alarm as the
system sees it). Include that report so the issue can be acted on.
- name: Scope and roadmap
url: https://codeberg.org/jlmakiola/clockula/src/branch/main/docs/ROADMAP.md
about: >-
What's built, what's planned, and what's deliberately out of scope —
check here before requesting a feature.
What's built, what's planned, and what's deliberately out of scope.
Check here before requesting a feature.
+4 -4
View File
@@ -1,6 +1,6 @@
---
name: Crash report
about: Report a crash. Clockula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
about: Report a crash. Clockula can capture this for you (Settings → Report a problem, or the prompt after a crash). It copies the report to your clipboard and prefills this form.
title: "Crash: "
labels:
- bug
@@ -11,10 +11,10 @@ labels:
<!--
Thanks for reporting a crash in Clockula!
If the app prefilled this for you, the crash report is already below — just add
what you were doing and submit. Otherwise, paste the report from your clipboard
If the app prefilled this for you, the crash report is already below. Add what
you were doing and submit. Otherwise, paste the report from your clipboard
into the code block. The report contains only app/Android/device versions and the
stack trace — no personal data or calendar content.
stack trace, with no personal data and none of your alarms, timers or clocks.
-->
### What happened
+1 -1
View File
@@ -9,7 +9,7 @@ labels:
### What would you like Clockula to do?
### Why — what problem does it solve?
### What problem would it solve?
### Anything else
+3 -3
View File
@@ -5,7 +5,7 @@ Please skim CONTRIBUTING.md if you haven't:
https://codeberg.org/jlmakiola/clockula/src/branch/main/CONTRIBUTING.md
Two things it's easy to get wrong:
• The one architectural rule — Room entities, DAOs and `@Query` strings never
• The one architectural rule: Room entities, DAOs and `@Query` strings never
leak above the data layer. Everything else talks to a repository.
• Don't bump `versionName` / `versionCode`. That bump reaching `main` is what
cuts a release, so it belongs only in a release PR.
@@ -16,7 +16,7 @@ Two things it's easy to get wrong:
### Why
<!-- Closes #123 — link the issue this implements or fixes. -->
<!-- Link the issue this implements or fixes, e.g. Closes #123. -->
### How it was tested
@@ -24,7 +24,7 @@ Two things it's easy to get wrong:
<!--
Which of these ran green, and anything you exercised by hand. On-device notes
are especially useful for UI changes, and essential for anything touching the
alarm engine — an alarm that doesn't ring is the one bug that matters most, and
alarm engine. An alarm that doesn't ring is the one bug that matters most, and
it only ever reproduces on a real device overnight.
./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug
+42 -1
View File
@@ -37,6 +37,47 @@ jobs:
- name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh
# The committed versionName must parse (X.Y.Z or X.Y.Z-beta.N) and the
# committed versionCode must be the one it derives: the official F-Droid
# repo builds the tag as committed. And a beta must never reach main,
# where the release pipeline would ship it to F-Droid and Play; betas are
# cut from release/* branches (docs/RELEASING.md).
- name: Committed version is well-formed
env:
BASE: ${{ github.base_ref }}
run: |
set -e
bash scripts/version_info.sh --check
if [ "${BASE#refs/heads/}" = "main" ] && [ "$(bash scripts/version_info.sh channel)" = "beta" ]; then
echo "ERROR: versionName $(bash scripts/version_info.sh version) is a beta." >&2
echo "Set the stable version (and its versionCode) before merging into main." >&2
exit 1
fi
# Also cheap, also always-on. Two failures in one: the script exits
# non-zero if this version's changelog is over the 500-character limit,
# and the porcelain check below catches a version with no committed
# changelog, which would otherwise ship the CHANGELOG.md section instead
# of a hand-written summary. A beta version passes both: it ships no
# What's New, so the script writes nothing for it.
- name: Changelog fits the stores, and is committed
run: |
set -e
bash scripts/sync_changelog_to_fastlane.sh
DIRTY=$(git status --porcelain fastlane/metadata/android/en-US/changelogs)
if [ -n "$DIRTY" ]; then
echo "$DIRTY"
echo "ERROR: no committed What's New for this version." >&2
echo "Write fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt" >&2
echo "(under 500 chars, every shipped locale) and commit it." >&2
exit 1
fi
# The fastlane tree feeds F-Droid and Play alike; Play rejects oversized
# text and off-spec graphics at upload time, so catch that on the PR.
- name: Store listing fits both stores
run: python3 scripts/check_store_listing.py
# Decide whether anything that affects the app build changed. Docs, store
# metadata, licence texts and forge housekeeping don't, so those PRs skip
# the SDK + Gradle work below but still report a green `ci`.
@@ -47,7 +88,7 @@ jobs:
# about defaults to building. Only paths the Gradle build provably
# never reads belong here — note that the workflows themselves, the
# `.gitmodules` submodule pointer and `scripts/` are *not* in it.
SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^licenses/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^LICENSE$)'
SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^Gemfile$|^licenses/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^LICENSE$)'
run: |
set -e
BASE="${{ github.base_ref }}"
+208
View File
@@ -0,0 +1,208 @@
name: Beta — Codeberg pre-release
# A beta is cut by pushing a release branch whose committed versionName is
# X.Y.Z-beta.N (see docs/RELEASING.md). Same model as release.yaml: the
# committed version is the trigger and the vX.Y.Z-beta.N tag is an output. If
# no tag exists for that version yet, this runs the unit tests, builds and
# signs the APK with the app key, records a Gitea pre-release (with the R8
# mapping) and publishes a Codeberg pre-release with the APK + SHA-256.
#
# Deliberately nothing else. A beta never reaches the F-Droid repos or Play:
# Obtainium hides pre-releases unless a user opts in, the official F-Droid
# recipe only picks up `^v[0-9.]+$` tags, and Codeberg's "latest release"
# skips pre-releases. Pushes of a release branch carrying a stable version
# (the usual state) fall through `detect` and do nothing.
#
# Lives in .gitea/workflows next to release.yaml for the same reason: it needs
# the app signing key, which only the self-hosted Gitea instance holds.
on:
push:
branches: ['release/**']
concurrency:
group: beta
cancel-in-progress: false
jobs:
detect:
# Gitea only; see the same guard in release.yaml.
if: github.repository_owner == 'makiolaj'
runs-on: docker
outputs:
is_beta: ${{ steps.v.outputs.is_beta }}
version: ${{ steps.v.outputs.version }}
version_code: ${{ steps.v.outputs.version_code }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve version and whether it is a new beta
id: v
env:
# Codeberg, not Gitea: see the same lookup in release.yaml.
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/clockula
run: |
set -e
INFO=$(bash scripts/version_info.sh)
echo "$INFO"
echo "$INFO" >> "$GITHUB_OUTPUT"
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
BASE=$(echo "$INFO" | sed -n 's/^base_version=//p')
if [ "$(echo "$INFO" | sed -n 's/^channel=//p')" != "beta" ]; then
echo "versionName $VERSION is not a beta — nothing to do."
echo "is_beta=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Fatal on anything but a clean 200/404, as in release.yaml: guessing
# "no tag" during an outage would re-cut a published beta.
# /tags/{tag}, not /git/refs/tags/{tag}: the refs endpoint matches by
# prefix, so v1.0.0 would "exist" as soon as v1.0.0-beta.1 did.
tag_status() {
curl -s -o /dev/null -w '%{http_code}' "$TAG_API/tags/$1" || echo 000
}
# A beta of a version that already shipped stable would carry a lower
# versionCode than the stable one: nobody could install it over it.
case "$(tag_status "v$BASE")" in
200)
echo "v$BASE already shipped as stable; a beta of it is pointless. Bump the version." >&2
exit 1 ;;
404) ;;
*) echo "Codeberg tag lookup for v$BASE failed — refusing to guess." >&2; exit 1 ;;
esac
STATUS=$(tag_status "v$VERSION")
case "$STATUS" in
200)
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
echo "is_beta=false" >> "$GITHUB_OUTPUT"
;;
404)
echo "No tag for v$VERSION on Codeberg yet — cutting the beta."
echo "is_beta=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
echo "Refusing to guess: treating this as 'no tag' could re-cut a published beta." >&2
exit 1
;;
esac
beta:
needs: detect
if: needs.detect.outputs.is_beta == 'true'
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: ''
- name: Setup Android SDK cache
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
"platform-tools" \
"platforms;android-37.0" \
"build-tools;36.0.0"
- name: Setup Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Install jq
run: |
set -e
SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update
$SUDO apt-get install -y jq
elif command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache jq
fi
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Pin versionCode to versionName
run: |
set -e
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
grep -E 'versionName|versionCode' app/build.gradle.kts
- name: Unit tests
run: ./gradlew testDebugUnitTest
# The real app key, same as a stable release: a beta has to update in
# place to the next beta and to the stable version.
- name: Setup Android Keystore
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
run: |
mkdir -p app
echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks
cat > key.properties <<EOF
storePassword=$KEY_PASSWORD
keyPassword=$KEY_PASSWORD
keyAlias=$KEY_ALIAS
storeFile=upload-keystore.jks
EOF
- name: Build release APK
run: ./gradlew assembleRelease
# Notes = a `## [X.Y.Z-beta.N]` section if there is one, else
# `## [Unreleased]`. Gitea creates the tag at this commit.
- name: Create tag + Gitea pre-release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
SHA: ${{ github.sha }}
run: |
set -e
bash scripts/release_notes.sh "$VERSION" > release-notes.md
cat release-notes.md
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/release/mapping.txt \
bash scripts/publish_gitea_release.sh
# The point of the whole workflow, so NOT continue-on-error.
- name: Publish pre-release to Codeberg
env:
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
API: https://codeberg.org/api/v1/repos/jlmakiola/clockula
SHA: ${{ github.sha }}
run: |
set -e
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/release/app-release.apk \
bash scripts/publish_codeberg_release.sh
+85 -252
View File
@@ -7,14 +7,14 @@ name: Release — F-Droid repo + Gitea/Codeberg release + Play
# 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
# trigger. Ordinary merges (no version bump) fall through `detect` and do
# nothing.
# nothing. Betas (X.Y.Z-beta.N) never come through here: beta.yaml cuts them
# from release/* branches as Codeberg-only pre-releases, and `detect` refuses
# one that reaches main.
#
# A trailing `play` job then uploads the App Bundle to Google Play. It is last
# and separate because Play is the only channel that can reject a good build for
# reasons the pipeline can't see, and that must not endanger a release which has
# already shipped to F-Droid and Codeberg. It skips cleanly until the
# PLAY_SERVICE_ACCOUNT_JSON secret exists — so it stays dormant through the
# whole pre-1.0 run, which is the correct behaviour anyway.
# and separate because Play can reject a good build for reasons the pipeline
# can't see, and that must not endanger a release which already shipped to
# F-Droid and Codeberg. It skips cleanly until PLAY_SERVICE_ACCOUNT_JSON exists.
#
# 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
@@ -75,23 +75,25 @@ jobs:
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/clockula
run: |
set -e
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
# Pre-1.0 is not stable yet: mark the Gitea release as a pre-release
# while MAJOR is 0. Graduates to a stable release automatically at 1.0.0.
if [ "$MAJOR" = "0" ]; then PRERELEASE=true; else PRERELEASE=false; fi
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
echo "Resolved version $VERSION (code $VERSION_CODE, prerelease=$PRERELEASE)"
# versionName -> versionCode, channel and the pre-release flag (set
# while MAJOR was 0) all come from the one script beta.yaml uses too.
INFO=$(bash scripts/version_info.sh)
echo "$INFO"
echo "$INFO" >> "$GITHUB_OUTPUT"
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
CHANNEL=$(echo "$INFO" | sed -n 's/^channel=//p')
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Manual dispatch — re-sign path, not a release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Betas ship from release/* branches via beta.yaml and must never be
# cut here: this path publishes to F-Droid and Play. CI blocks such a
# PR into main; this is the backstop if one gets through anyway.
if [ "$CHANNEL" != "stable" ]; then
echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2
exit 1
fi
# A tag for this version already existing means the release shipped on
# an earlier push; do nothing. Absent => this merge cuts the release.
#
@@ -100,7 +102,11 @@ jobs:
# 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)
#
# /tags/{tag}, not /git/refs/tags/{tag}: the refs endpoint matches by
# prefix, so v1.0.0 would "exist" once v1.0.0-beta.1 does and the
# stable release would never be cut.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/tags/v$VERSION" || echo 000)
case "$STATUS" in
200)
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
@@ -117,6 +123,16 @@ jobs:
;;
esac
# Before a single Gradle task runs: F-Droid truncates the in-client
# changelog, so an over-long one would reach users cut off mid-sentence.
# The script exits non-zero past the limit. Cheap enough to sit in the
# gate job, where failing costs nothing and publishes nothing — the step
# further down that regenerates the file for the repo would otherwise be
# the first thing to notice, after the build and the signing.
- name: Changelog fits the stores
if: steps.v.outputs.is_release == 'true'
run: bash scripts/sync_changelog_to_fastlane.sh
# 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
# re-uploads the existing index (recovery path).
@@ -188,8 +204,8 @@ jobs:
run: chmod +x ./gradlew
# The committed versionName is the source of truth. Pin versionCode to the
# value derived from it so the published APK's code is always
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
# value scripts/version_info.sh derives from it, so the published APK's
# code follows the scheme even if the committed code was forgotten.
- name: Pin versionCode to versionName
if: env.IS_RELEASE == 'true'
run: |
@@ -324,6 +340,8 @@ jobs:
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
# this commit, so the tag only ever marks a fully-shipped release (and a
# failure before here leaves no tag, so re-running the workflow retries).
# Also attaches the R8 mapping (best-effort) so user crash stacktraces
# stay deobfuscatable. Notes = this version's CHANGELOG section.
- name: Create tag + Gitea release
if: env.IS_RELEASE == 'true'
env:
@@ -332,217 +350,52 @@ jobs:
SHA: ${{ github.sha }}
run: |
set -e
TAG="v$VERSION"
# Notes = this version's CHANGELOG section.
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
sed -i -e '/./,$!d' release-notes.md
if [ ! -s release-notes.md ]; then
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
fi
python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > payload.json
import json, sys
print(json.dumps({
"tag_name": sys.argv[1],
"target_commitish": sys.argv[2],
"name": sys.argv[1],
"body": open("release-notes.md").read(),
"draft": False,
# Pre-1.0 releases are flagged as pre-releases (see detect job).
"prerelease": sys.argv[3] == "true",
}))
PY
# Upsert (re-run safe): PATCH if a release for the tag already exists,
# else POST a new one (which also creates the tag at target_commitish).
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
if [ -n "$ID" ]; then
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @payload.json "$API/releases/$ID")
OK=200
else
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @payload.json "$API/releases")
OK=201
fi
cat response.json
if [ "$CODE" != "$OK" ]; then
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
exit 1
fi
echo "Created/updated release $TAG at $SHA"
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
# Attached to the release (it's not an APK, so it fits the no-binaries
# rule). Best-effort: never fail a release over it.
- name: Attach R8 mapping to Gitea release
if: env.IS_RELEASE == 'true'
continue-on-error: true
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
MAP="app/build/outputs/mapping/release/mapping.txt"
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
TAG="v$VERSION"
ASSET="mapping-${VERSION}.txt.gz"
gzip -c "$MAP" > "/tmp/$ASSET"
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
# Replace any prior asset of the same name (re-run safe).
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
bash scripts/release_notes.sh "$VERSION" > release-notes.md
TAG="v$VERSION" NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/release/mapping.txt \
bash scripts/publish_gitea_release.sh
# Publish the release on Codeberg, which is canonical for tags and
# releases (see docs/RELEASING.md). Codeberg push-mirrors branches + tags
# to Gitea, but releases aren't git objects and don't sync in either
# direction — so this step pushes the tag straight to Codeberg and creates
# the release there over the API, attaching the signed APK plus a SHA-256
# checksum as the direct-download channel for users who don't want
# F-Droid. The APK is identical to the F-Droid one (same app key), so this
# adds no trust surface. Needs the CODEBERG_RELEASE_TOKEN secret; skips
# cleanly if unset.
# releases (see docs/RELEASING.md): push the tag there and attach the
# signed APK plus a SHA-256 checksum as the direct-download channel for
# users who don't want F-Droid. The APK is identical to the F-Droid one
# (same app key), so this adds no trust surface. Needs the
# CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
- name: Publish release to Codeberg
if: env.IS_RELEASE == 'true'
# NOT continue-on-error, and inherited that way deliberately: in
# Agendula this step reported green through five consecutive releases
# (0.2.1 through 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.
# 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:
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
API: https://codeberg.org/api/v1/repos/jlmakiola/clockula
SHA: ${{ github.sha }}
run: |
set -e
if [ -z "${TOKEN:-}" ]; then
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
exit 0
fi
TAG="v$VERSION"
APK="app/build/outputs/apk/release/app-release.apk"
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
ASSET_APK="clockula_v${VERSION}.apk"
ASSET_SUM="${ASSET_APK}.sha256"
cp "$APK" "/tmp/$ASSET_APK"
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
TAG="v$VERSION" NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/release/app-release.apk \
bash scripts/publish_codeberg_release.sh
# Release notes: reuse the section extracted for the Gitea release,
# fall back to the CHANGELOG entry if that step's file is gone.
if [ ! -s release-notes.md ]; then
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
sed -i -e '/./,$!d' release-notes.md
fi
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
# Push the tag to Codeberg ourselves. Under Codeberg-canonical the
# mirror runs Codeberg -> Gitea, so waiting for a tag to arrive here
# from Gitea (what Agendula 0.3.2 did) would wait forever. The tag this
# pipeline minted on Gitea is in fact *deleted* by the next mirror
# sync until Codeberg has it — so pushing it here is what makes it
# durable on both forges.
#
# 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 push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/clockula.git" \
"refs/tags/$TAG"
python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json
import json, sys
tag, pre = sys.argv[1:3]
print(json.dumps({
"tag_name": tag,
"name": tag,
"body": open("release-notes.md").read(),
"draft": False,
# Pre-1.0 releases are flagged as pre-releases (see detect job).
"prerelease": pre == "true",
}))
PY
# Create (or update) the release. Codeberg 500s on a POST/GET against a
# tag it has only just received — the release request outruns the
# 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 \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases/$EXIST"
ID="$EXIST"; break
fi
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-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.
for A in "$ASSET_APK" "$ASSET_SUM"; do
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$A" \
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."
# Play takes an App Bundle, not the APK, so it is a second artifact from
# the same source and the same signing config — not a repackage of the
# APK. The release key signs it, but Play only ever treats that key as the
# *upload* key: Play App Signing re-signs with Google's own key before
# delivery. A Play install and an F-Droid install therefore carry
# different signatures and cannot update each other. That divergence is a
# deliberate, documented choice (docs/RELEASING.md), not an accident.
# Play takes an App Bundle, not the APK: a second artifact from the same
# source and signing config. Play treats the release key only as the
# upload key and re-signs with its own (Play App Signing), so Play and
# F-Droid installs carry different signatures and can't update each other.
#
# Built LAST and `continue-on-error`, both deliberately: everything above
# has already shipped by this point, and nothing Play-related may put that
# at risk. Sitting mid-job without continue-on-error, this block took the
# whole Calendula 2.17.0 release down with it — no F-Droid publish, no tag, no
# Codeberg mirror — over an artifact upload. A failure here now costs the
# Play upload and nothing else.
#
# Nothing here touches the F-Droid path: the AAB is never copied into the
# repo, never attached to a release, and its build cannot change the APK
# published above.
#
# AGP embeds the R8 mapping in the bundle's BUNDLE-METADATA, so Play gets
# deobfuscated stacktraces without a separate mapping upload.
# Built last and continue-on-error: everything above has already shipped,
# and nothing Play-related may take it down. The AAB never touches the
# F-Droid repo or the releases. AGP embeds the R8 mapping in the bundle,
# so Play gets deobfuscated stacktraces without a separate upload.
- name: Build release AAB
if: env.IS_RELEASE == 'true'
continue-on-error: true
run: ./gradlew bundleRelease
# NOT actions/upload-artifact@v4: it runs @actions/artifact v2, which
# refuses to start whenever GITHUB_SERVER_URL is not github.com — it reads
# any other forge as an unsupported GHES instance and fails before it ever
# talks to the server (go-gitea/gitea#36024). Gitea 1.25 serves the v4
# artifact API fine; only the client-side check is wrong. This fork is that
# client with the check removed. Pinned to a commit, not the v4 branch: a
# third-party action in the signing pipeline must not change under us.
# NOT actions/upload-artifact@v4: its client refuses any non-github.com
# server as unsupported GHES (go-gitea/gitea#36024). This fork drops that
# check. Pinned to a commit — a third-party action in the signing
# pipeline must not change under us.
- name: Hand the AAB to the Play job
if: env.IS_RELEASE == 'true'
continue-on-error: true
@@ -553,46 +406,34 @@ jobs:
if-no-files-found: error
retention-days: 14
# Google Play channel.
# Google Play channel. A separate job after the F-Droid publish and both forge
# releases, so a Play rejection (policy review, API outage, listing rules)
# shows up as one red job next to a release that already shipped.
#
# A separate job, on purpose, running only AFTER the F-Droid publish and both
# forge releases have completed. Play is the one channel that can reject a
# perfectly good build for reasons outside the pipeline (listing rules, policy
# review, API outage, a track that needs manual promotion). Isolating it means
# such a rejection surfaces as one red job next to a release that already
# shipped everywhere else, instead of failing the workflow that publishes it.
#
# Not a `container:` job even though a fastlane image exists: act_runner does
# not provide node inside custom job containers, so JavaScript actions
# (checkout, download-artifact) can't run there. The Renovate job gets away
# with a container because its only step is a shell command. Ruby is installed
# the same way sshpass, jq and fdroidserver are in the job above.
# Not a `container:` job: act_runner provides no node inside custom job
# containers, so JavaScript actions (checkout, download-artifact) can't run.
play:
needs: [detect, release]
# workflow_dispatch is the F-Droid re-sign recovery path — it must never
# touch Play, so gate on a real release only.
# workflow_dispatch is the F-Droid re-sign recovery path; never touch Play.
if: needs.detect.outputs.is_release == 'true'
runs-on: docker
env:
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
# Where the bundle lands. `production` — the release itself is already the
# gate (a bumped versionName only reaches main after on-device review), so
# a second manual promotion in the Play Console bought nothing but delay.
# Override with the PLAY_TRACK repo variable to stage a release instead.
# The release itself is the gate (a bumped versionName only reaches main
# after on-device review), so it goes straight to production. Override
# with repo variables to stage instead.
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'production' }}
PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }}
# Set PLAY_DRY_RUN=true to validate the edit against the API and discard
# it instead of committing — used to rehearse the first upload.
# true validates the edit against the API and discards it.
PLAY_DRY_RUN: ${{ vars.PLAY_DRY_RUN || 'false' }}
BUNDLE_PATH: vendor/bundle
steps:
- name: Checkout
uses: actions/checkout@v4
# Skip cleanly (not fatally) when Play isn't configured yet, so the rest
# of the release pipeline keeps working during setup — same contract as
# the Codeberg mirror step.
# Skip cleanly when Play isn't configured yet, same contract as the
# Codeberg publish.
- name: Write the Play service-account key
id: key
env:
@@ -605,14 +446,11 @@ jobs:
exit 0
fi
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
# Fail here, with a clear message, rather than inside fastlane: a
# mangled multi-line secret is the likeliest setup mistake.
python3 -c "import json,sys; d=json.load(open('play-service-account.json')); sys.exit(0 if d.get('type')=='service_account' else 1)" \
|| { echo "PLAY_SERVICE_ACCOUNT_JSON is not a valid service-account JSON." >&2; exit 1; }
echo "configured=true" >> "$GITHUB_OUTPUT"
# Same GHES-detection problem as the upload side, same fix — see the
# handoff step in the release job.
# Same GHES-detection fix as the upload side.
- name: Download the AAB
if: steps.key.outputs.configured == 'true'
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 # v4
@@ -627,12 +465,10 @@ jobs:
SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
$SUDO apt-get update
# ruby-dev + build-essential: several of fastlane's dependencies build
# native extensions.
# Several fastlane dependencies build native extensions.
$SUDO apt-get install -y ruby-full ruby-dev build-essential
ruby -v
# Only the first release pays the full gem build; afterwards this restores.
- name: Cache bundled gems
if: steps.key.outputs.configured == 'true'
uses: actions/cache@v4
@@ -655,15 +491,12 @@ jobs:
if: steps.key.outputs.configured == 'true'
env:
SUPPLY_JSON_KEY: play-service-account.json
# supply is chatty on a TTY-less runner otherwise.
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_HIDE_CHANGELOG: '1'
run: |
set -euo pipefail
# Absolute: a lane body runs from fastlane/, not the workspace root.
AAB="$GITHUB_WORKSPACE/dist/app-release.aab"
# Absolute, because a lane body runs from fastlane/, not the
# workspace root — a relative path resolves against the wrong
# directory there and Calendula 2.17.1 died on exactly that.
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
bundle exec fastlane deploy \
aab:"$AAB" \
@@ -672,8 +505,8 @@ jobs:
dry_run:"$PLAY_DRY_RUN"
echo "Uploaded $VERSION (code $VERSION_CODE) to the '$PLAY_TRACK' track."
# The workspace is reused between runs on a self-hosted runner, so the
# credential must not outlive the job.
# The workspace is reused on a self-hosted runner; the key must not
# outlive the job.
- name: Shred the service-account key
if: always()
run: shred -u play-service-account.json 2>/dev/null || rm -f play-service-account.json
+11 -2
View File
@@ -1,8 +1,17 @@
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).
# Every merge to main. Mirror syncs from Codeberg fire push events here (the
# same trigger release.yaml relies on), so a merged Renovate PR is followed
# within minutes by a run that rebases the sibling PRs it just conflicted —
# most bumps touch gradle/libs.versions.toml. Outside renovate.json5's
# `schedule` window such a run only maintains existing branches
# (updateNotScheduled), it never opens new PRs. Renovate's own rebases push
# to renovate/* branches, not main, so this cannot loop.
push:
branches: [main]
# Weekly sweep for new updates. Mondays 05:00 UTC, inside the schedule window
# in renovate.json5 — keep the two in step.
schedule:
- cron: '0 5 * * 1'
# Manual run for an on-demand sweep from the Actions tab.
+51 -233
View File
@@ -4,240 +4,58 @@ All notable changes to Clockula are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Tag sections feed the release notes — see [`docs/RELEASING.md`](docs/RELEASING.md).
Tag sections feed the release notes; see [`docs/RELEASING.md`](docs/RELEASING.md).
## [Unreleased]
The first release of Clockula: alarms, timers, a stopwatch and a world clock.
### Added
- Project skeleton: Gradle build, version catalog, Hilt, Compose and the Material 3
Expressive theme over floret-kit's `identity` module, seeded `#6B7A5C` — the
third 120° hue rotation of Calendula's slate, completing the family's palette.
- floret-kit as a git submodule wired in as a Gradle composite build.
- Crash capture and the report surface via floret-kit's `core-crash`; per-app
language support via `core-locale`.
- Launcher and notification icons: a line-art clock face carrying the family's
Calendula bloom badge.
- Full release pipeline, Codeberg-canonical from the first commit — CI,
translations, release (F-Droid repo + Codeberg release + Google Play) and
Renovate, with the F-Droid reproducibility rules intact.
- The locked specification: `docs/PLAN.md` and `docs/ROADMAP.md`.
- Preferences over floret-kit's new `core-prefs`: `SettingsPrefs` holds the
appearance slice (theme mode, dynamic colour) in a DataStore of Clockula's own,
under the family's key names. The theme now follows the stored preference —
light/dark override and dynamic colour persist across launches instead of
always following the system — and an unrelated preference write no longer
re-emits it.
- floret-kit's `core-di` supplies the `@IoDispatcher` the preference store runs
on, so Clockula no longer declares its own dispatcher qualifier.
- Clockula's own storage, headless: a Room database with `alarms`, `timers`,
`world_clocks` and `stopwatch_laps`, its schema exported and committed at
every version so each migration is reviewable and testable.
- Plain-Kotlin alarms, timers, world clocks and stopwatch runs behind four
repository interfaces exposing Flows — nothing above the data layer knows Room
exists, and a test fails the build if anyone reaches through. A corrupt row
degrades on read (a clamped time, an unknown ringtone, a rotted zone id)
rather than crashing the list it appears in.
- Alarm and timer settings are inherited, not copied: a per-alarm ringtone,
snooze, vibrate, volume ramp or dismiss challenge is an *override*, so
changing an app default later reaches every alarm the user never customised.
- The clock defaults and the stopwatch's running state now live in DataStore
alongside the appearance preferences, clamped on read as well as on write, so
a hand-edited or restored file cannot produce a setting the user could never
have chosen.
- The wall-clock / elapsed-realtime split, made explicit: alarm records follow
the system clock, while running timers and the stopwatch are anchored to time
since boot. Changing the device's time — forwards or backwards — cannot warp
either, and a timer that survives a reboot falls back to its wall-clock
estimate and says so instead of vanishing.
- Alarms that ring. A repeat schedule that is re-resolved against the device's
zone every time anything moves, so the clock going forwards, backwards or
through a daylight-saving transition cannot lose one: a 02:30 alarm on a
spring-forward night rings at 03:30 rather than vanishing, and on a fall-back
night rings once rather than twice.
- Skip-next-occurrence that skips exactly one occurrence, snooze with a
per-alarm interval and limit, and a snooze that is still kept after a reboot.
- An alarm that keeps ringing through a reboot or a process kill — the ring is
rebuilt from storage, not from memory — and one that still rings when the
full-screen-intent or notification permission is denied. The chain of
fallbacks ends in vibration, never in silence.
- The post-reboot repair: a running timer no longer counts down from an anchor
the reboot killed, and the stopwatch no longer invents a segment it never ran.
- The app shell: four tabs — alarms, timers, stopwatch, world clock — under a
navigation bar that becomes a rail on a tablet, chosen by the window's size
rather than by hand. Back from a tab goes home to Alarms; back from Alarms
leaves the app, and the predictive-back gesture previews whichever of the two
is actually about to happen.
- The live pill: whatever is counting is visible from every tab, and pausable
and stoppable without going to find the row that owns it. It stays up while
paused — a control that vanishes under the thumb that pressed it is no control
— shows how many other timers are running behind it, and flips a timer that
reaches zero to "finished" the moment it does, rather than counting into
negative time. Stop resets; it never deletes a timer the user configured.
- The real ring screen, over the lock screen with the display kept on for
exactly as long as the window lives: the time, the label, snooze, and an
optional dismiss challenge — a two-term sum, or a two-second hold — that can
never strand anyone. Three wrong answers or sixty seconds of ringing, either
one, opens a way out; snooze is never gated; solving the challenge dismisses
on the spot instead of asking twice; and the hold completes from an
accessibility click, so a screen reader is not locked out of its own alarm.
Back cannot silence an alarm.
- The first Compose motion: fade-through between peer tabs, the kit's springs for
the pill coming and going and for the challenge block opening, all of which
already stand down when the system's "remove animations" setting is on.
- Notification permission is asked for once, on first launch, and the fact that
it was asked is recorded rather than guessed at.
- The alarms tab, for real: a list of your alarms that says when each one will
ring — "in 9h 12m" — and marks the next one to go off, with the repeat days
and a switch on every row. A switched-off alarm stays where it is, faded
rather than shuffled to the bottom, and the countdown keeps up on its own
without anything being saved.
- Create, edit, delete, enable and disable an alarm, applied as you make them
rather than behind a Save button: tapping + creates an alarm at the next whole
hour and opens it, every control writes at once, and back is just back.
- The Material time picker, with a keypad to type a time and the dial behind one
toggle, following the device's own 12- or 24-hour setting.
- A repeat-day selector that starts on your locale's first day of the week, not
on a hardcoded Monday, and a "skip next alarm" switch for a repeating one —
so you can sleep in on Thursday without switching the alarm off and
forgetting to switch it back.
- A per-alarm sound, vibration, gradual volume, snooze length, snooze limit and
dismiss challenge — each of which can say "follow the app default" rather than
silently copying today's default and drifting away from it later. Every row
shows what it currently resolves to.
- A ringtone picker with the device's own alarm sounds, a file you choose
yourself, and a Silent option that still vibrates — because an alarm that does
literally nothing is not an alarm. You can play a sound before taking it.
- A sound Clockula cannot read right now says so under the row, and your choice
is kept rather than quietly replaced with the default: an unmounted card comes
back. If a file you picked cannot be held onto permanently, that is disclosed
too instead of being hidden.
- The timers tab, for real: several timers at once, each with its own label,
length and sound, and a keypad that sets one in a couple of taps — with saved
presets you can add to and remove from as you go. A timer starts the moment
you press Start, so an accidental tap leaves nothing behind to delete.
- Pause, resume, reset and "+1 min" on every timer, from the row, from the live
pill and from the notification — and "+1 min" on a timer that has just rung
starts it again with a whole minute, rather than handing back a paused timer
you have to press Start on.
- A notification that counts the timer down without waking the device every
second: the system draws the ticking, so Clockula posts once when something
actually changes. It carries the same two buttons the row does, and they work
with the app closed.
- A timer keeps counting across a reboot and rings when you come back — the
countdown is anchored on the device's uptime, so changing the system clock
cannot move it in either direction. Change the clock while a timer runs and
the notification's countdown follows the change instead of going wrong.
- Two timers finishing together share one sound and one ten-minute window, and
each is stopped on its own, so you can still tell which one you have dealt
with. A timer left ringing goes quiet after ten minutes but stays finished, so
you can come back and add a minute to it — and the next timer to finish rings
for itself rather than inheriting that silence.
- A timer that finishes while an alarm is ringing waits its turn instead of
talking over it: it is still marked finished, its notification says so, and it
starts sounding the moment the alarm is done.
- Progress on each timer card is drawn by the Expressive wavy indicator, and the
wave itself is the state — it flattens when you pause and when the timer is
done.
- The stopwatch tab, for real: start, pause and reset, with a readout down to
hundredths of a second that stays legible because the fraction is drawn
smaller than the seconds rather than shouting alongside them.
- Laps, each with its own split and its running total, newest at the top, and
the fastest and slowest of them marked — in colour and in words, so a screen
reader hears the distinction too. The lap you are in the middle of has its own
row, counting up.
- An ongoing notification with Lap and Pause in the shade, which costs no
battery to keep accurate because the system draws the ticking; Clockula posts
once when something actually changes.
- A stopwatch that keeps counting while the app is closed, and that comes back
after a restart paused — with every lap intact — at the time it had banked,
rather than inventing the time it lost. Changing the system clock, in either
direction, cannot move it at all.
- The app's big numbers now sit in fixed columns, so a readout no longer jitters
as its digits change — on every screen that shows one, not only the stopwatch.
- A world clock tab, with an analog face for your own time zone whose dial
changes shape with the hour — so a glance says whether it is the middle of the
night there, without a line of text saying so.
- Cities added from the device's own time-zone database, searched by name in
your language — accents and all, so typing "sao" finds São Paulo — with the
country and the offset from GMT shown before you pick.
- Each city shows its time, how far ahead or behind your own it is, and whether
it is a different day; all of it re-read from the real time-zone rules, so a
daylight-saving change in either place is right on the day it happens.
- Cities reordered by dragging them, or by a "move up" / "move down" action a
screen reader can reach — a list you can only rearrange by dragging is a list
some people cannot rearrange at all.
- A home time zone you can set by hand or leave following the device, and which
every city's offset and day difference are measured against.
- Other apps can now drive Clockula. The whole `android.provider.AlarmClock`
contract is answered — setting an alarm, setting a timer, showing the alarms
or the timers, dismissing an alarm, snoozing one and dismissing a timer — so
an assistant, an automation app or a watch companion can ask for any of it.
- Nothing another app sends can make Clockula write something you did not ask
for: a nonsense hour opens the alarm in the editor instead of setting one at
some other time, an unusable sound falls back to your default instead of
blocking the alarm, and a request that matches two of your alarms asks which
one you meant rather than guessing. A link to an alarm or a timer that cannot
be read dismisses nothing at all, rather than falling back to "all of them".
- An alarm or timer set by voice without showing you a screen removes itself
once it has been dismissed, so talking to your assistant every morning no
longer leaves a list of dead 06:30 alarms behind.
- A settings screen, reachable from a gear icon on every tab: theme, dynamic
colour and language, the alarm/timer/clock defaults every new alarm or timer
inherits, help, and the app version.
- Back up your alarms, timers and world clocks to a JSON file in a folder you
choose, and restore them on this device or another one. Restoring replaces
everything currently on the device — the app says so before you confirm.
Nothing about a ringing alarm or a running timer's live state travels with
the backup, only the configuration.
- A "why might my alarm not ring?" screen that checks the actual state of your
device — exact-alarm and notification permissions, full-screen alerts,
battery optimisation, Do Not Disturb, your alarm volume, and whether the
system has actually scheduled Clockula's next alarm — and links straight to
whichever settings page would fix each one.
- Three home-screen widgets: your next alarm, a running or paused timer (with
pause, resume and reset right on the widget), and a clock that grows to show
your world clocks at larger sizes. Each one updates the moment something
changes — no polling, no stale numbers.
- Wake up to your own music: "Choose from a music app…" in the sound picker
lets any app that shares its library (VLC, AntennaPod and many others) play
your alarm or timer — a playlist, an album, a single track, or just whatever
it played last. The music app does the playing, so Clockula still has no
internet permission. If the app can't play — uninstalled, logged out,
offline, or stops partway — your alarm rings with the default sound instead.
It does the same when the music might not be heard: headphones or a
Bluetooth device connected, or Do Not Disturb muting media.
- Swipe left or right to move between Alarms, Timers, Stopwatch and World
clock. Phones only: tablets and foldables with a side rail keep tap
navigation.
- The World clock tab shows your home time as text under the analog face, in
your 12/24-hour format.
- The quick-timer widget can start any duration up to 59:59: pick "Custom…" in
its settings and type it on the timer keypad.
- Widgets can follow the system theme or always be light or dark, and on
Android 12+ use the app's own colours instead of the wallpaper's. This is set
per widget in its settings; the next-alarm and timer widgets now have a
settings screen for it, and the analog and digital clocks offer it on
Android 12+.
### Changed
- Tapping the alarm icon in the status bar, or the next-alarm line on the lock
screen, now opens your alarms. It used to open the ringing screen for an alarm
that was not ringing, which closed itself again straight away.
- The database moved to version 3, adding one column to the alarms and timers
tables for the alarms and timers another app asked to be temporary. Existing
alarms and timers are unaffected and stay exactly as they were.
- Tapping the digital or analog clock widget opens Alarms, and each clock
widget has an "On tap" setting to open Alarms, Timers, Stopwatch or World
clock instead.
### Removed
- The "Default duration" setting under Settings > Timers. It had no effect,
since new timers are typed on the keypad. Older backup files that contain it
still import.
### Fixed
- The add-timer keypad shrinks to fit short screens, landscape, split screen
and large display or font sizes, so the Start button is never cut off. The
same goes for the keypad on the empty Timers tab.
- Tapping a widget sometimes showed the screen the app was last left on
instead of the widget's tab.
- **Alarms that ring.** Alarms are scheduled as real alarm clocks, exempt from
battery saving and shown on the lock screen and in the status bar. They
survive a reboot, a process kill and a change of the device's clock, and
daylight-saving nights neither skip nor double one. If the full-screen or
notification permission is denied, an alarm still rings. The last fallback
is vibration, never silence.
- An alarm list that shows when each alarm will ring and marks the next one.
Changes apply as you make them, with no Save button.
- Repeat days that start on your locale's first day of the week, and a "skip
next alarm" switch so you can sleep in once without turning the alarm off.
- Per-alarm sound, vibration, gradual volume, snooze length and limit, and a
dismiss challenge (a quick sum or a two-second hold). Each one can follow the
app default, so changing a default later still reaches it.
- Your own sounds: the device's alarm tones, a file you pick, Silent (which
still vibrates), or music from an app that shares its library, such as VLC
or AntennaPod. If the music can't play, or might not be heard, the alarm
rings with the default sound instead.
- A ring screen over the lock screen with snooze and dismiss. The dismiss
challenge always gives you a way out, works with a screen reader, and Back
can't silence an alarm.
- **Timers.** Run several at once, each with its own label, length and sound,
set from a keypad with saved presets. Pause, resume, reset and +1 min work
from the timer, from the live pill and from the notification. Timers keep
counting through a reboot and aren't affected by changes to the clock.
- **Stopwatch** with laps, the fastest and slowest marked, and Lap and Pause
in the notification. It keeps counting while the app is closed.
- **World clock.** An analog face for your own time, plus cities from the
device's time-zone database, searchable in your language. Each city shows
the offset from your time and whether it's a different day there. Reorder
them by dragging or with a screen reader, and set a home time zone or follow
the device.
- A live pill that shows whatever is counting from every tab.
- Swipe between tabs on phones. Tablets and foldables get a side rail.
- Other apps can set, show, snooze and dismiss alarms and timers through
Android's standard alarm intents, so assistants and automation apps work
with Clockula. A voice-set alarm cleans up after itself once dismissed.
- Settings: theme, dynamic colour, language, and the defaults every new alarm
and timer inherits.
- Back up alarms, timers, world clocks and settings to a JSON file, and restore
them on this device or another one.
- "Why might my alarm not ring?", a self-check that reads your device's real
state: permissions, battery optimisation, Do Not Disturb, alarm volume, and
whether the system has Clockula's next alarm. It links straight to the
setting that fixes each problem.
- Home-screen widgets: next alarm, timer (with controls), a quick timer, and
analog and digital clocks that show your world clocks at larger sizes. Each
widget can follow the system theme or stay light or dark, and choose what a
tap opens.
+27 -24
View File
@@ -1,8 +1,8 @@
# Contributing to Clockula
Thanks for your interest in Clockula — a Material 3 Expressive clock app:
Thanks for your interest in Clockula, a Material 3 Expressive clock app for
alarms, timers, stopwatch and world clock. Unlike its siblings it owns its
storage, because there is no open provider behind a clock. Before diving in, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
storage, because there is no open provider behind a clock. Before you start, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
(how it's built), [`docs/ROADMAP.md`](docs/ROADMAP.md) (what's next), and
[`docs/PLAN.md`](docs/PLAN.md) (the design rationale). This file covers the
practical how.
@@ -15,14 +15,14 @@ layer.** Clockula owns its storage, which makes this rule more important here
than in the siblings, not less: it is the only thing keeping the schema a
private implementation detail rather than the app's de-facto domain model. If a
change would expose a Room type to a ViewModel or the UI, it's in the wrong
layer — see [`docs/PLAN.md`](docs/PLAN.md) §0.
layer; see [`docs/PLAN.md`](docs/PLAN.md) §0.
## Prerequisites
- JDK 17
- Android SDK: compileSdk 37, build-tools 36.0.0 (the Gradle wrapper handles AGP/Kotlin)
- A real device for anything touching the alarm engine. An emulator will not
tell you whether an alarm survives doze, a reboot, or an OEM battery-saver —
tell you whether an alarm survives doze, a reboot, or an OEM battery-saver,
and that is the only question that matters about an alarm.
## Build, test, lint
@@ -43,11 +43,11 @@ before opening a PR. Keep CI green.
owned by a self-hosted Weblate that writes to this repository directly, and a
hand-edit is overwritten on the next sync.
→ **[Translate Clockula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/clockula/)**
**[Translate Clockula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/clockula/)**
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
fine; missing keys are informational. Stale and orphaned keys are not, so run
```sh
python3 scripts/check_translations.py
@@ -57,64 +57,67 @@ 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
`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
| Layer | Lives in | Rule of thumb |
|---|---|---|
| Pure logic (models, filtering, sorting, form validation, date maths) | `domain/` | No Android imports — must be JVM-unit-testable. |
| Provider access | `data/tasks/` | The only place that knows about the provider. New provider work goes through `TasksDataSource`. |
| Reminders, prefs, DI, demo data | `data/reminders/`, `data/prefs/`, `data/di/`, `data/demo/` | |
| Pure logic (models, scheduling and ring policies, formatting, the self-check rules) | `domain/` | No Android imports; must be JVM-unit-testable. |
| Storage | `data/` (`db/`, `alarms/`, `timers/`, `stopwatch/`, `worldclocks/`, `prefs/`, `backup/`) | The only place that knows Room or DataStore exists. Everything above it goes through a repository interface. |
| Engines and their Android seams | `alarm/`, `timer/`, `stopwatch/`, `ring/`, `interop/`, `widget/` | The engine is plain Kotlin; AlarmManager, services and receivers sit behind seams in its `android/`, `receiver/` and `service/` subpackages. |
| Screens | `ui/<area>/` | One ViewModel + immutable `UiState` per area; Compose for the screen. |
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §3 lists every package.
## Code style & conventions
- **Kotlin**, 4-space indent, LF line endings, final newline, no trailing
whitespace — all enforced by `.editorconfig` (2-space for yaml/toml/json/md).
whitespace, all enforced by `.editorconfig` (2-space for yaml/toml/json/md).
Match the surrounding code.
- **Material 3 Expressive** for all UI: use `MaterialExpressiveTheme`, the
colour-scheme tokens (never hardcoded colours), and canonical M3 components
(e.g. `ListItem` for rows). Consult the `material-3` skill before designing a
new screen or component.
- Prefer the domain layer for anything testable; keep `AndroidTasksDataSource`
the only Android-coupled data implementation so the rest stays JVM-testable.
- Prefer the domain layer for anything testable; keep Android APIs behind the
seams in each engine's `android/` subpackage so the rest stays JVM-testable.
## Tests
- New domain logic (mappers, filters, sorting, forms, value mapping) **must**
come with JVM unit tests under `app/src/test/`. The data source is the
JVM-testable seam — mock or fake it rather than reaching for instrumentation.
- Add an instrumented test only when a path genuinely needs a real
`ContentResolver`.
- New domain logic (scheduling, ring policies, formatting, mappers) **must**
come with JVM unit tests under `app/src/test/`. Repositories and engine seams
are the JVM-testable boundary: fake them rather than reaching for
instrumentation.
- Add an instrumented test only when a path genuinely needs a real device
(Room migrations, AlarmManager, a foreground service).
## Commits & PRs
- Write focused commits with clear messages (the existing history uses short,
scoped subjects like `UI: ...` / `M1 ...`).
conventional subjects like `feat(widgets): ...` / `fix(ringtones): ...`).
- Update [`CHANGELOG.md`](CHANGELOG.md) under `[Unreleased]` for any
user-visible change — its sections feed the release notes and F-Droid "What's
user-visible change. Its sections feed the release notes and F-Droid "What's
New" (see [`docs/RELEASING.md`](docs/RELEASING.md)).
- If your change shifts the architecture or completes a milestone, update
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) / [`docs/ROADMAP.md`](docs/ROADMAP.md)
in the same PR.
- Don't bump `versionName` / `versionCode` in a regular PR — the committed
- Don't bump `versionName` / `versionCode` in a regular PR. The committed
`versionName` is bumped only when **cutting a release** (that bump reaching
`main` is what triggers the release; the pipeline then mints the tag). See
[`docs/RELEASING.md`](docs/RELEASING.md).
## Scope
v1 is four surfaces — alarms, timers, stopwatch, world clock — done properly,
v1 is four surfaces (alarms, timers, stopwatch, world clock) done properly,
and nothing else. Widgets, a Quick Settings tile, screensaver mode and a bedtime
schedule are all wanted, and all deliberately deferred until v1 is finished; see
[`docs/ROADMAP.md`](docs/ROADMAP.md).
Two things are out of scope by design. **A sync stack of our own** — your alarms
Two things are out of scope by design. **A sync stack of our own**: your alarms
export to a documented JSON file, and syncing them is a job for something that
already does syncing well. **Anything that makes an alarm less reliable in
exchange for a feature** — if a change trades away the certainty that an alarm
exchange for a feature**: if a change trades away the certainty that an alarm
rings, it loses, however nice the feature is.
## License
+3 -7
View File
@@ -1,10 +1,6 @@
source "https://rubygems.org"
# fastlane is used ONLY to upload the release bundle to Google Play
# (see fastlane/Fastfile). It is not part of the build or the signing path, so
# it never runs on a PR — only in release.yaml's `play` job.
#
# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is
# committed on purpose: this resolves an uploader's transitive deps, not the
# app's, and none of it affects the reproducible release build.
# fastlane is used ONLY as the Google Play Developer API client (see
# fastlane/Fastfile). It never builds and never signs. Pinned exactly, no
# Gemfile.lock: it resolves an uploader's deps, not the app's.
gem "fastlane", "2.237.0"
+31 -14
View File
@@ -11,16 +11,20 @@ Alarms, timers, stopwatch and world clock. Open standards, no account required.<
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a>
<p>
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/clockula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
</p>
</div>
Clockula is the clock sibling to [Calendula](https://codeberg.org/jlmakiola/calendula)
(calendar) and [Agendula](https://codeberg.org/jlmakiola/agendula) (tasks). The
name follows theirs: *clock*, given Calendula's `-ula` ending. A Calendula flower
head is botanically a cluster of many small *florets*, so the three apps are
florets of one bloom — and they share one, [floret-kit](https://codeberg.org/jlmakiola/floret-kit).
florets of one bloom. They also share one library, [floret-kit](https://codeberg.org/jlmakiola/floret-kit).
> 🚧 **Early development.** The spec is locked; the app is being built. See
> [`docs/ROADMAP.md`](docs/ROADMAP.md) for where it actually is.
> 🧪 **Beta.** Clockula 1.0.0 is in beta: feature-complete, and being tested
> before the stable release. See [Install](#install) to try it.
## The honest difference from its siblings
@@ -28,31 +32,44 @@ Calendula is a pure front-end over Android's `CalendarContract`. Agendula speaks
the dmfs `TaskContract`. Both put a good interface on a standard someone else
already maintains.
**There is no open provider behind a clock.** Alarms, timers and laps are
There is no open provider behind a clock. Alarms, timers and laps are
Clockula's own data, so Clockula is the first app in the family to own its
storage — by design, not as a fallback. What that commitment is replaced with is
the first section of [`docs/PLAN.md`](docs/PLAN.md), and it comes down to three
things:
storage, and does so by design. The first section of
[`docs/PLAN.md`](docs/PLAN.md) sets out what takes the place of a shared
standard, and it comes down to three things:
- **Clockula speaks Android's standard alarm contract.** It implements
- Clockula speaks Android's standard alarm contract. It implements
`android.provider.AlarmClock` in full, so your assistant and your automation
apps can set alarms and timers in it exactly as they would in any stock clock,
and the system status bar and lock screen show its next alarm.
- **World clocks are IANA time zones**, not a bespoke city list that goes stale.
- **Your data exports** to a documented, versioned JSON file whenever you want
- World clocks are IANA time zones, not a bespoke city list that goes stale.
- Your data exports to a documented, versioned JSON file whenever you want
it, because data you can't take with you isn't really yours.
## Alarms that actually ring
An alarm app is only worth having if you can trust it, so Clockula treats that as
the feature rather than the plumbing. Alarms are scheduled as real alarm clocks —
doze-exempt, visible to the system — and re-resolved against your current time
An alarm app is only worth having if you can trust it, so Clockula treats reliability
as the main feature. Alarms are scheduled as real alarm clocks (doze-exempt and
visible to the system) and re-resolved against your current time
zone whenever anything moves underneath them, so DST and travel don't silently
shift them.
When something on your device *would* stop an alarm ringing, Clockula tells you
which setting it is and takes you there, instead of failing quietly at 7am.
## Install
Betas are published as pre-releases on
[Codeberg](https://codeberg.org/jlmakiola/clockula/releases), with the signed
APK and a `.sha256` checksum attached. Download the APK there, or add
`https://codeberg.org/jlmakiola/clockula` to
[Obtainium](https://github.com/ImranR98/Obtainium) and switch on *Include
prereleases* for it so it finds the beta. A beta updates in place to later betas
and to the stable release.
From the stable 1.0.0 on, every release also goes to the self-hosted F-Droid
repository that serves Calendula and Agendula.
## Documentation
Start with [`docs/`](docs/): [`PLAN.md`](docs/PLAN.md) is the spec and the
@@ -75,4 +92,4 @@ in a (gitignored) `floret-kit/local.properties`.
## Licence
MIT — see [`LICENSE`](LICENSE).
MIT. See [`LICENSE`](LICENSE).
+13 -5
View File
@@ -27,11 +27,13 @@ android {
// These committed values ARE the source of truth for a release: merging
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// 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.
versionCode = 100
versionName = "0.1.0"
// release itself. A versionName of X.Y.Z-beta.N pushed to a release/*
// branch instead cuts a Codeberg-only pre-release (beta.yaml).
// versionCode is derived from versionName by scripts/version_info.sh
// (1.0.0-beta.1 -> 1000001, 1.0.0 -> 1000099), and CI fails if the
// committed one doesn't match. See docs/RELEASING.md.
versionCode = 1000001
versionName = "1.0.0-beta.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -110,6 +112,12 @@ android {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
// Ship the prebuilt .so files (datastore's shared counter) exactly as the
// AAR has them. AGP strips them only when an NDK happens to be installed,
// so CI and F-Droid's buildserver would otherwise disagree on the bytes.
jniLibs {
keepDebugSymbols += "**/*.so"
}
}
lint {
+393 -395
View File
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -1,6 +1,6 @@
# Clockula backup format
`clockula-backup-v1.json` — a documented, versioned JSON export of Clockula's
`clockula-backup-v1.json` is a documented, versioned JSON export of Clockula's
own data, written to (and read from) a user-chosen folder via SAF
(`docs/PLAN.md` §7). This file is the schema's source of truth; the codec
(`data/backup/BackupDto.kt`, `BackupCodec.kt`) implements exactly what is
@@ -20,18 +20,18 @@ written here.
}
```
- `format` — must be exactly `"clockula-backup"`.
- `version` — must be exactly `1`. Any other value rejects the whole file:
- `format`: must be exactly `"clockula-backup"`.
- `version`: must be exactly `1`. Any other value rejects the whole file:
Clockula does not guess at a schema it has not implemented.
- `exportedAt` — epoch milliseconds, informational only (not re-imported).
- `exportedAt`: epoch milliseconds, informational only (not re-imported).
## Parsing rules
- **Tolerant of unknown fields, at any level.** An older or newer client's
extra field is ignored, never an error.
- **Strict about known fields.** A malformed value in a field this schema
defines — `format`, `version`, an alarm's `hour`/`minute`, a world clock's
`zone_id`, a timer's `duration_millis` — rejects the **whole file** with a
defines (`format`, `version`, an alarm's `hour`/`minute`, a world clock's
`zone_id`, a timer's `duration_millis`) rejects the **whole file** with a
specific reason. Nothing is imported half-way.
- Settings values (snooze minutes, volume ramp, …) are
**not** range-checked at parse time; they pass through the same clamps
@@ -41,19 +41,19 @@ written here.
- An enum field with a name this version does not recognise (a `dismiss_
challenge` or `theme_mode` from a future version, say) degrades to that
field's default, the same forgiving-read posture `AlarmMapper` already uses
for a corrupt Room row — it does not fail the import.
for a corrupt Room row. It does not fail the import.
## Import semantics
Import **replaces all state** — alarms, timers and world clocks are deleted
Import **replaces all state**: alarms, timers and world clocks are deleted
and re-created from the file, and every setting the file carries is written
over the current value. This is not a merge, and the backup screen says so
before the user confirms. A ringing alarm is dismissed and any running timer
is deleted *before* the replace, so the import never leaves an orphaned ring
session; alarm/timer/stopwatch scheduling is re-resolved immediately after.
Every imported alarm and timer gets a **fresh id and fresh timestamps** —
the backup carries no id and no `created_at`/`updated_at`, since those are
Every imported alarm and timer gets a **fresh id and fresh timestamps**.
The backup carries no id and no `created_at`/`updated_at`, since those are
local-database facts, not portable ones.
## Alarms
@@ -77,7 +77,7 @@ One object per row of `alarms`, config only:
| `delete_after_use` | Boolean | A transient, contract-created alarm. |
**Not exported:** the `alarm_states` ring-state table (snooze/ringing/handled-
occurrence bookkeeping) — it is volatile, per-device state, not configuration,
occurrence bookkeeping). It is volatile, per-device state, not configuration,
and `docs/ARCHITECTURE.md` §4/§6 are explicit that it must never appear here.
## Timers
@@ -93,7 +93,7 @@ One object per row of `timers`, config only:
| `delete_after_use` | Boolean | |
**Not exported:** `state`, `remaining`, the elapsed-realtime anchors, or
`ends_at_wall_clock` — a timer's running state is meaningless on another
`ends_at_wall_clock`. A timer's running state is meaningless on another
device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
`IDLE`, with `remaining` equal to `duration`.
@@ -128,12 +128,12 @@ files may also carry `default_timer_duration_millis`, from a default timer
length setting that has since been removed; it is ignored on import like any
other unknown field. Every
numeric/duration value is clamped on *apply*, through the existing pref
setters — never on parse.
setters, never on parse.
## Ringtone URIs
Exported exactly as stored (a `content://` URI, typically). On import the
URI is written back unchanged; if the *importing* device cannot resolve it
at ring/preview time, the existing ringtone-resolution fallback already used
elsewhere in the app applies (falls through to the device default) — this is
elsewhere in the app applies (falls through to the device default). This is
not special-cased in the backup path itself.
+34 -34
View File
@@ -1,6 +1,6 @@
# Clockula — implementation plan
> The **design decisions and their rationale** — the "why". Locked unless
> The design decisions and their rationale (the "why"). Locked unless
> explicitly overturned here. For the moving status view see
> [`ROADMAP.md`](ROADMAP.md); for the shape of the code as it actually stands,
> [`ARCHITECTURE.md`](ARCHITECTURE.md) (written from M2 onward).
@@ -32,7 +32,7 @@ in three concrete places:
1. **The platform alarm contract.** Clockula implements `android.provider.AlarmClock`
in full (§6), so Assistant, automation apps and anything else that speaks the
standard intent vocabulary can drive it. That contract *is* the open interface
for clocks on Android — the same role `CalendarContract` plays for Calendula.
for clocks on Android, the same role `CalendarContract` plays for Calendula.
Being a good citizen of it is the thesis, applied.
2. **IANA tzdata.** World clocks are IANA zone IDs, not a bespoke city table (§5).
3. **Your data leaves whenever you want.** A documented, versioned JSON backup
@@ -44,7 +44,7 @@ Clockula owns storage, but **nothing above the data layer knows Room exists**.
The app talks to `AlarmRepository` / `TimerRepository` / `WorldClockRepository` /
`StopwatchRepository`; only the DAOs and one data source per aggregate touch Room
entities. Domain models are plain Kotlin. **Never let a Room entity or a
`@Query` string leak above the data layer** — the same discipline that let
`@Query` string leak above the data layer**. That is the same discipline that let
Agendula swap its whole storage posture without touching the UI.
---
@@ -89,7 +89,7 @@ recurrence) are a different animal from a due-date nudge.
## 3. floret-kit
Consumed as a git submodule + Gradle composite build, as the siblings do —
Consumed as a git submodule + Gradle composite build, as the siblings do:
`includeBuild("floret-kit")`, `implementation("de.jeanlucmakiola.floret:<module>")`,
with a gitignored `clockula/floret-kit/local.properties` locally and
`ANDROID_HOME` in CI.
@@ -128,7 +128,7 @@ Everything else in this app is a readout. This is the engineering.
`TIME_SET`, `TIMEZONE_CHANGED`, and `MY_PACKAGE_REPLACED`. DST transitions are
handled by this re-resolution, not by arithmetic on stored millis. A 02:30
alarm on a spring-forward night has a defined, tested behaviour.
- Scheduled with `AlarmManager.setAlarmClock()` — not `setExactAndAllowWhileIdle`.
- Scheduled with `AlarmManager.setAlarmClock()`, not `setExactAndAllowWhileIdle`.
`setAlarmClock` is the only variant the system treats as a user-visible alarm:
it is doze-exempt, and it populates `getNextAlarmClock()`, which is what draws
the status-bar alarm icon and the lockscreen line. Permission is
@@ -147,7 +147,7 @@ notification whose content intent is the ring activity.
- Full-screen intent on Android 14+ needs `USE_FULL_SCREEN_INTENT`; check
`canUseFullScreenIntent()` and deep-link to the grant screen when denied. A
denied FSI must degrade to a high-priority heads-up notification that still
rings — never to silence.
rings, never to silence.
- Gradual volume ramp, per-alarm ringtone (system `RingtoneManager` URIs, so the
user's existing sounds work), vibration pattern, `AudioAttributes` with
`USAGE_ALARM` so DND's alarm exemption applies.
@@ -162,7 +162,7 @@ notification whose content intent is the ring activity.
A "**Why might my alarm not ring?**" screen that reports actual device state, not
generic advice: exact-alarm permission, notification permission, full-screen-intent
permission, battery-optimisation exemption, **whether DND currently blocks alarms**
(read via `NotificationManager.getCurrentInterruptionFilter()` — Clockula never
(read via `NotificationManager.getCurrentInterruptionFilter()`; Clockula never
requests `ACCESS_NOTIFICATION_POLICY` or changes DND itself, it only reports what the
user already has), whether the OEM has Clockula under an aggressive app-killer, and
the next scheduled fire time as the system sees it (`getNextAlarmClock()`), compared
@@ -175,19 +175,19 @@ the exact settings page. This screen is a v1 feature, not a support afterthought
Room, schema exported and version-controlled, migrations tested from v1.
- **`alarms`** — id, hour, minute, label, enabled, repeat-day mask, ringtone URI,
- **`alarms`**: id, hour, minute, label, enabled, repeat-day mask, ringtone URI,
vibrate, snooze minutes, snooze limit, volume-ramp seconds, dismiss challenge,
`skipNextOccurrence`, timestamps.
- **`timers`** — id, label, duration, state (idle/running/paused/expired), the
- **`timers`**: id, label, duration, state (idle/running/paused/expired), the
elapsed-realtime anchor it resolves against, ringtone, sort order. Multiple
concurrent timers are first-class.
- **`world_clocks`** — id, IANA zone ID, optional custom label, sort order.
- **`stopwatch_laps`** — id, index, split, cumulative. The stopwatch's running
- **`world_clocks`**: id, IANA zone ID, optional custom label, sort order.
- **`stopwatch_laps`**: id, index, split, cumulative. The stopwatch's running
state lives in DataStore (a single record), its laps in Room so they survive
process death.
Running timers and the stopwatch anchor on **`SystemClock.elapsedRealtime()`**,
never wall-clock — a user changing the time must not warp a running timer. Alarms
never wall-clock: a user changing the time must not warp a running timer. Alarms
are the opposite: wall-clock by definition. This distinction is the single
easiest thing to get wrong in a clock app; it is tested both ways.
@@ -226,12 +226,12 @@ than writing anything. `SKIP_UI` never means "skip validation".
"timers": […], "worldClocks": […], "settings": {…} }`. The schema is **documented
in the repo** (`docs/BACKUP.md`), versioned, and import is tolerant of unknown
fields (ignored, at any level) and strict about known ones (a malformed known
field rejects the whole file — nothing is imported half-way). Ringtone URIs are
field rejects the whole file, so nothing is imported half-way). Ringtone URIs are
exported as-is with a documented fallback when the target device can't resolve
them.
Import is **whole-state replacement**, not a merge: the user is shown exactly
that before confirming. Alarm and timer records carry only configuration — ring
that before confirming. Alarm and timer records carry only configuration. Ring
state (`alarm_states`, a timer's running anchors) is never exported and never
touched by an import; an imported timer always lands `IDLE`. Record field names
mirror the Room column vocabulary (§5), so the schema, the exported Room schema
@@ -241,7 +241,7 @@ and the backup all read the same words.
## 8. Identity
Seed **`#6B7A5C`** — a muted sage/olive.
Seed **`#6B7A5C`**, a muted sage/olive.
The family's seeds are one colour rotated: Calendula `#5C6B7A` (slate blue) →
Agendula `#7A5C6B` (warm mauve) → Clockula `#6B7A5C` (sage). Same three bytes,
@@ -254,10 +254,10 @@ fallback, hand-tuned light/dark `ColorScheme`s in `ui/theme/Color.kt`.
Design principles inherited from the family (non-negotiable):
- **Expressive ≠ big or bold.** Refinement comes from shape, colour, space and
motion — never from enlarging text or upping weight.
motion, never from enlarging text or upping weight.
- Content lives **on surfaces**, not as bare text on the background.
- `MaterialShapes` / `androidx.graphics.shapes` morphs are for the genuinely
clock-specific surfaces — the analog face, the ring screen, timer progress —
clock-specific surfaces (the analog face, the ring screen, timer progress),
not sprinkled everywhere.
A clock app is mostly one enormous number. That readout is where the design
@@ -267,24 +267,24 @@ lives, and it is the thing to get right before anything else.
## 9. Navigation
Four tabs — Alarm, Timer, Stopwatch, World clock — in an M3 navigation bar, with
Four tabs (Alarm, Timer, Stopwatch, World clock) in an M3 navigation bar, with
a **live pill** floating above it whenever a timer or the stopwatch is running:
current value plus pause and stop, reachable from any tab.
This is the one deliberate departure from the stock model, and it exists because
the stock clock's worst flaw is that a running timer is invisible unless you are
standing on its tab. The pill is not decoration; it is the fix.
standing on its tab.
Adaptive: navigation rail on wide layouts, per M3 adaptive guidance.
### Home-screen widgets (M10)
Three Jetpack Glance app widgets, each a read-only mirror of state that already
exists — no widget owns data: **next alarm** (day, time, label), **timer**
exists (no widget owns data): **next alarm** (day, time, label), **timer**
(the live pill's subject timer, same selection logic, with pause/resume/reset),
and **clock** (local time, growing to world clocks at larger sizes). Tapping a
widget opens the matching tab through the same `MainActivity` intents the
`AlarmClock` contract (§6) already uses — a widget is just another caller of
`AlarmClock` contract (§6) already uses. A widget is just another caller of
that door, not a second entry point. Refresh is event-driven (`updatePeriodMillis
= 0`): widgets update when the state they mirror changes, not on a timer.
@@ -292,24 +292,24 @@ that door, not a second entry point. Refresh is event-driven (`updatePeriodMilli
## 10. Repository & pipeline
**Codeberg is canonical from the first commit** — git, issues, PRs and releases.
**Codeberg is canonical from the first commit**: git, issues, PRs and releases.
The self-hosted Gitea instance holds the secrets and runs the release job, and
push-mirrors *from* Codeberg. There is no Gitea-canonical phase to migrate out
of, which is the mistake Agendula had to unwind.
The pipeline is copied wholesale from the siblings:
- `.forgejo/workflows/ci.yaml` — contributor-facing, no secrets. Copy Agendula's,
- `.forgejo/workflows/ci.yaml`: contributor-facing, no secrets. Copy Agendula's,
adjusting `SKIP_RE`.
- `.forgejo/workflows/translations.yaml` — identical in both siblings; copy as-is.
- `.gitea/workflows/release.yaml` — **Agendula's spine** (Codeberg-canonical: pushes
- `.forgejo/workflows/translations.yaml`: identical in both siblings; copy as-is.
- `.gitea/workflows/release.yaml`: **Agendula's spine** (Codeberg-canonical: pushes
the tag to Codeberg itself, pre-1.0 prerelease flag, and the Codeberg publish
step is *not* `continue-on-error`, after five Calendula-era releases reported
green while never publishing) **plus Calendula's `play` job** grafted on
unchanged — last, isolated, `continue-on-error`, skipping cleanly until
unchanged. It runs last, isolated and `continue-on-error`, and skips cleanly until
`PLAY_SERVICE_ACCOUNT_JSON` exists. It therefore stays dormant through the
pre-1.0 run, which is the correct behaviour anyway.
- `.gitea/workflows/renovate.yml` — Agendula's, including the
- `.gitea/workflows/renovate.yml`: Agendula's, including the
`github.repository_owner == 'makiolaj'` guard.
- Issue templates, PR template, F-Droid metadata, fastlane tree.
@@ -331,8 +331,8 @@ useful reference for the parts of an alarm app that are tedious to rediscover.
- **Default posture: read it, don't copy it.** Understanding how it schedules and
how its services are wired is free; retyping our own implementation from that
understanding keeps Clockula cleanly MIT.
- **Where we genuinely lift code** — realistically only intricate scheduling or
service-lifecycle edge cases — that file keeps its Apache-2.0 header and
- **Where we genuinely lift code** (realistically only intricate scheduling or
service-lifecycle edge cases), that file keeps its Apache-2.0 header and
attribution, is listed in `docs/PROVENANCE.md`, and the Apache text ships in
`licenses/`. This is exactly the pattern Agendula already uses for the vendored
dmfs provider, so it is a known, working arrangement rather than a new risk.
@@ -348,16 +348,16 @@ DAO/repository, and the Room entity shapes as a sanity check on §5.
- Its **always-on foreground service** (`AlarmAlwaysForegroundService`). Keeping a
permanent service alive to make alarms reliable is a battery-cost workaround;
`setAlarmClock` is the supported mechanism and does not need it.
- Its **Pomodoro** feature — out of scope.
- Its **Pomodoro** feature, which is out of scope.
- Its **settings/tile component vocabulary** (`ui/components/tiles/*`,
`SettingTile`) — floret-kit's `GroupedRow` / `OptionCard` / `OptionPicker` are
`SettingTile`). floret-kit's `GroupedRow` / `OptionCard` / `OptionPicker` are
our equivalents and keep the family coherent.
- **MaterialKolor + a colour-picker dependency** — the kit's `identity` module
- **MaterialKolor + a colour-picker dependency**. The kit's `identity` module
owns theming.
- Its **no-Hilt, manual-ViewModel structure** and `minSdk 24`.
For interaction detail — snooze behaviour, the ring screen, timer presentation,
what the stopwatch does with laps — **Google Clock is the reference**, not
For interaction detail (snooze behaviour, the ring screen, timer presentation,
what the stopwatch does with laps), **Google Clock is the reference**, not
ClockMaster.
---
+4 -4
View File
@@ -1,12 +1,12 @@
# Clockula — documentation
Clockula is a Material 3 Expressive **clock** app for Android — alarms, timers,
Clockula is a Material 3 Expressive **clock** app for Android: alarms, timers,
stopwatch and world clock. Third floret of the bloom, after
[Calendula](https://codeberg.org/jlmakiola/calendula) (calendar) and
[Agendula](https://codeberg.org/jlmakiola/agendula) (tasks). See the top-level
[`../README.md`](../README.md) for the pitch.
Unlike its siblings, Clockula owns its storage — there is no open provider behind
Unlike its siblings, Clockula owns its storage, because there is no open provider behind
a clock. What that costs and how the open-standards commitment is honoured anyway
is the first section of [`PLAN.md`](PLAN.md).
@@ -15,8 +15,8 @@ is the first section of [`PLAN.md`](PLAN.md).
| Doc | What it covers |
|---|---|
| [`PLAN.md`](PLAN.md) | **The spec.** Thesis, locked decisions, the alarm engine, data model, system interop, identity, pipeline, and how ClockMaster is used. The "why". |
| [`ROADMAP.md`](ROADMAP.md) | **Status and the work queue** — milestones M0–M11 and post-v1. What the build loop works through. |
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Clockula is built **today** — layers, the data seam, the alarm engine, DI, build/tooling, manifest. Written from M2 onward. |
| [`ROADMAP.md`](ROADMAP.md) | **Status and the work queue**: milestones M0–M11 and post-v1. What the build loop works through. |
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Clockula is built **today**: layers, the data seam, the alarm engine, DI, build/tooling, manifest. Written from M2 onward. |
| [`RELEASING.md`](RELEASING.md) | How to cut a release. Written at M0 alongside the pipeline. |
| [`PROVENANCE.md`](PROVENANCE.md) | Any third-party code carried in-tree and under what licence. Created only if we actually lift something (`PLAN.md` §11). |
+242 -143
View File
@@ -1,37 +1,51 @@
# Clockula — releasing
Clockula is distributed through a **self-hosted F-Droid repo** (on Hetzner) and a
**Codeberg release** per version carrying the signed APK as a direct download.
Both are produced automatically by `.gitea/workflows/release.yaml` when a
**bumped `versionName` reaches `main`** — the pipeline builds and publishes that
Clockula is distributed through a self-hosted F-Droid repo (on Hetzner), a
Codeberg release per version carrying the signed APK as a direct download,
and Google Play. All three are produced automatically by `.gitea/workflows/release.yaml` when a
**bumped `versionName` reaches `main`**. The pipeline builds and publishes that
version, then creates the matching `vX.Y.Z` tag and the releases itself. The
parallel **Gitea release** is the changelog of record on the build instance and
parallel Gitea release is the changelog of record on the build instance and
carries no APK assets.
Codeberg is the canonical forge; Gitea is build infrastructure. See
[Two forges, one repo](#two-forges-one-repo) for how the two are wired.
While Clockula 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.
Before a stable release you can ship **betas** (`X.Y.Z-beta.N`) from the
release branch. They go to **Codeberg only**, flagged as pre-releases; F-Droid
and Play never see them. See [Cutting a beta](#cutting-a-beta).
Clockula's first public build is `1.0.0-beta.1`; there was never a `0.x` release.
---
## The source of truth: the committed version
A release is defined by the `versionName`/`versionCode` committed in
`app/build.gradle.kts` — **not** by a hand-pushed tag:
`app/build.gradle.kts`, **not** by a hand-pushed tag:
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `0.2.0`)
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`0.2.0` → `200`,
`1.3.4` → `10304`)
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `1.0.0`), or
`MAJOR.MINOR.PATCH-beta.N` for a beta (e.g. `1.0.0-beta.2`)
- `versionCode` is derived from it by `scripts/version_info.sh`:
So `MINOR` and `PATCH` each have room for 0–99. The release pipeline reads
`versionName`, pins `versionCode` to the derived value, builds, publishes, and —
once the APK is live — creates the tag `v<versionName>` at that commit. The tag
is an **output** of a successful release, not its trigger, so a tag always marks
a fully-shipped version (and a failure before publish leaves no tag, so
re-running the workflow safely retries).
| `versionName` | `versionCode` | Example |
| --- | --- | --- |
| `X.Y.Z-beta.N` (N = 1–98) | `X*1000000 + Y*10000 + Z*100 + N` | `1.0.0-beta.2` → `1000002` |
| `X.Y.Z` | `X*1000000 + Y*10000 + Z*100 + 99` | `1.0.0` → `1000099` |
A beta's code sits below its own stable release and above everything before
it, so a beta install updates in place to the next beta and then to the stable
version. `MINOR` and `PATCH` each have room for 0–99.
Run `scripts/version_info.sh` to see what the committed version resolves to;
CI fails a PR whose committed `versionCode` doesn't match, because the official
F-Droid repo builds the tag exactly as committed.
The release pipeline reads `versionName`, pins `versionCode` to the derived
value, builds, publishes, and, once the APK is live, creates the tag
`v<versionName>` at that commit. The tag is an **output** of a successful
release, not its trigger, so a tag always marks a fully-shipped version (and a
failure before publish leaves no tag, so re-running the workflow safely
retries).
---
@@ -39,66 +53,118 @@ re-running the workflow safely retries).
1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the
feature/fix branches for this release into it. Everything below happens on
that branch, before it reaches `main`.
that branch, before it reaches `main`. To ship betas first, follow
[Cutting a beta](#cutting-a-beta) from here and come back to step 2 when
going stable.
2. **Update `CHANGELOG.md`.** Move the `## [Unreleased]` items under a new
`## [X.Y.Z]` heading (Keep a Changelog format). The text between that heading
and the next `## [` becomes both the Gitea release notes and the F-Droid
per-version "What's New". The heading **must** match the version exactly.
3. **Bump the committed `versionName`** (and `versionCode`) in
`app/build.gradle.kts`. **This bump is what triggers the release** when the
branch merges to `main`. Then run
```sh
scripts/sync_changelog_to_fastlane.sh
```
and commit the generated
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` — this is what
makes the **official** F-Droid listing (which harvests the changelog from the
tagged source tree) show this version. The self-hosted pipeline regenerates it
regardless, so forgetting only affects the official listing.
4. **Verify the release build on a real device** — the mandatory gate:
and the next `## [` becomes the Gitea and Codeberg release notes. The
heading **must** match the version exactly.
3. **Bump the committed `versionName`** (and `versionCode`, which
`scripts/version_info.sh` prints) in `app/build.gradle.kts`. **This bump is what triggers the release** when the
branch merges to `main`.
Then write this version's **"What's New"** by hand:
`fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt` (e.g.
`1000099.txt` for 1.0.0), one per
shipped locale, each a short summary **under 500 characters**. F-Droid and
Play both publish these files, and Play rejects a longer one. It is not a
copy of the CHANGELOG.md section. `scripts/sync_changelog_to_fastlane.sh`
keeps a committed `en-US` file as it is and only generates one from
CHANGELOG.md when it is missing; CI fails a PR whose version has no
committed `en-US` changelog.
4. **Verify the release build on a real device.** This is the mandatory gate:
```sh
scripts/verify-release.sh
```
It builds the `releaseTest` variant (same R8 config as `release`, debug-signed
with a `.releasetest` suffix so it installs alongside the real app) and resets
it to a first-run state. Then, on the device:
- launch from a **clean / permission-not-granted** state — the permission
screen must appear, no crash;
- grant tasks access — the task list must load;
- create a task with a due reminder and confirm the notification fires;
- launch from a **clean / permission-not-granted** state: notification
permission is asked for, no crash;
- set an alarm a minute out and lock the device: it must ring over the lock
screen with sound and vibration, and snooze and dismiss must both work;
- reboot with that alarm armed: it must survive and still fire;
- start a timer and the stopwatch, background the app: both keep time and
stay controllable from the notification shade;
- exercise this release's headline changes.
Only proceed once all of that passes on-device.
5. **Merge `release/vX.Y.Z` into `main`.** That's it — no manual tagging. The
merge triggers `release.yaml`, which detects the new version, builds, signs,
publishes to F-Droid, and creates the `vX.Y.Z` tag + Gitea release.
The script prints the same list.
> The `releaseTest` build type exists only for step 4 — it is never published.
Only proceed once all of that passes on-device.
5. **Merge `release/vX.Y.Z` into `main`.** There is no manual tagging. The
merge triggers `release.yaml`, which detects the new version, builds, signs,
publishes to F-Droid, creates the `vX.Y.Z` tag + releases, and uploads the
App Bundle to Play.
> The `releaseTest` build type exists only for step 4 and is never published.
> The pipeline always builds and signs the real `release` variant.
---
## Cutting a beta
A beta is a test build of the next version, published as a **pre-release on
Codeberg** and nowhere else. It is signed with the real app key, so testers
install it over their stable install and it updates in place to later betas
and to the stable release.
1. **On `release/vX.Y.Z`**, with the features merged in, set
`versionName = "X.Y.Z-beta.1"` and the matching `versionCode`
(`scripts/version_info.sh` prints it; `1.0.0-beta.1` → `1000001`).
No What's New file, since betas don't ship to the stores. Notes come from a
`## [X.Y.Z-beta.N]` section of `CHANGELOG.md` if you write one, otherwise
from `## [Unreleased]`. So keep the entries under `## [Unreleased]` while
betas are going out and only move them to `## [X.Y.Z]` when going stable:
once they're moved, a beta's notes come out empty.
2. **Optionally verify it on a device** with `scripts/verify-release.sh`, as for
a stable release.
3. **Push the branch to Codeberg.** When it reaches Gitea, `beta.yaml` sees a
beta version without a tag, runs the unit tests, builds and signs the APK,
creates the `vX.Y.Z-beta.1` tag + a Gitea pre-release (with the R8 mapping)
and publishes the **Codeberg pre-release** with the APK + `.sha256`.
4. **Next round:** bump to `-beta.2` (and its `versionCode`) and push again.
5. **Going stable:** set `versionName = "X.Y.Z"` and its `versionCode`
(`1.0.0` → `1000099`), then continue from step 2 of
[Cutting a release](#cutting-a-release).
Who gets a beta:
- **Obtainium** users only with *Include prereleases* switched on for the app.
That is how a tester opts in; everyone else stays on stable.
- **F-Droid** (self-hosted and official) never: `beta.yaml` doesn't touch the
self-hosted repo, and the official recipe's `UpdateCheckMode: Tags ^v[0-9.]+$`
ignores `-beta` tags.
- **Play** never.
Guards: a beta version can't reach `main` (CI fails the PR, and `release.yaml`'s
`detect` refuses one as a backstop), `beta.yaml` refuses a beta of a version
that already shipped stable.
---
## What the pipeline 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:
- **`ci.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**) — the
- **`ci.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**): the
reproducible-release invariant guard
(`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
build but still report a green `CI` check.
- **`translations.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**)
— an SDK-free parity check (`scripts/check_translations.py`) over
- **`translations.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**):
an SDK-free parity check (`scripts/check_translations.py`) over
`values-*/strings.xml`, so Weblate PRs get fast feedback. Runs on every PR
without a path filter, so the required `Translations / check` status is always
reported.
- **`renovate.yml`** (`.gitea/workflows/`, Mondays 05:00 UTC plus
`workflow_dispatch`, **Gitea**) — the dependency sweep. Runs the pinned
Renovate image as its job container and targets Codeberg's API; the cadence
lives here, not in `renovate.json5`, so the two don't double-gate.
- **`renovate.yml`** (`.gitea/workflows/`, Mondays 05:00 UTC, on push to
`main`, plus `workflow_dispatch`, **Gitea**): the dependency sweep. Runs the
pinned Renovate image as its job container and targets Codeberg's API. New
PRs only open in the Monday `schedule` window in `renovate.json5` (keep it
bracketing the cron); the on-merge runs fall outside it and only rebase
existing PRs that the merge left conflicted.
- **`release.yaml`** (`.gitea/workflows/`, on push to `main` plus
`workflow_dispatch`, **Gitea**) — a cheap `detect` job reads `versionName` and
`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,
@@ -106,13 +172,19 @@ release work when a merge actually cuts a release:
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.
The pipeline ends with a separate `play` job that uploads the App Bundle to
Google Play. It runs last and in isolation so a Play rejection cannot endanger
a release that has already shipped to F-Droid and Codeberg, and it skips
cleanly until Play is configured.
**Codeberg** with the signed APK + a SHA-256 checksum, and finally builds the
App Bundle. Ordinary merges with no version bump fall through `detect` and do
nothing.
- **`beta.yaml`** (`.gitea/workflows/`, on push to `release/**`, **Gitea**):
the same `detect` idea for betas. Only when the committed `versionName` is
`X.Y.Z-beta.N` and Codeberg has no tag for it does the `beta` job run: unit
tests, pin `versionCode`, build & sign the release APK with the **app key**,
create the tag + a Gitea pre-release with the R8 mapping, and publish the
**Codeberg pre-release** with the APK + SHA-256. No F-Droid, no Play.
- **`play` job** (same workflow, after `release`): uploads the App Bundle and
every locale's "What's New" to Google Play. Runs last and separately so a Play
rejection can't endanger a release that already shipped; skips cleanly until
Play is configured.
### Codeberg direct-download channel
@@ -122,77 +194,54 @@ Releases aren't git objects and don't sync with the push mirror in either
direction, so the pipeline pushes the `vX.Y.Z` tag straight to Codeberg, creates
the release over the Codeberg API, and attaches `clockula_v<version>.apk` + its
`.sha256`. It's the same APK the F-Droid repo serves (same **app key**), so it
adds no trust surface. 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
adds no trust surface. The logic lives in `scripts/publish_codeberg_release.sh`,
which `beta.yaml` uses too (with the pre-release flag set). It skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset,
but it is **not** `continue-on-error`: in Agendula 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
`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope
— it pushes the tag as well as creating the release) added to Gitea Actions.
### Google Play channel
Play is a third channel alongside F-Droid and the Codeberg download, and it is
the only one that gets a different artifact and a different signature.
**Artifact.** Play gets an **App Bundle** (`bundleRelease`), never the APK. It is
built from the same source and signing config at the very end of the `release`
job, `continue-on-error`, and handed to the `play` job as a workflow artifact
(via a commit-pinned fork of `upload-artifact`/`download-artifact`; the official
v4 actions refuse any non-github.com forge). The APK and the F-Droid
reproducibility guarantee are untouched.
**Artifact.** Play takes an **App Bundle** (`bundleRelease`), not the APK. It is
a second output of the same source and the same signing config — never a
repackage of the published APK, which stays untouched so the F-Droid
reproducibility guarantee is unaffected. The bundle is built at the very **end**
of the `release` job, after everything else has shipped, and both it and the
handoff to the `play` job are `continue-on-error` — nothing Play-related may
take down a release that is already published. The handoff uses a patched
`upload-artifact`/`download-artifact` fork pinned to a commit: the official v4
actions read any non-github.com forge as an unsupported GHES instance and refuse
to run on Gitea (go-gitea/gitea#36024). `dependenciesInfo` stays disabled for
the bundle too; Play's "app dependencies" report is optional and re-enabling it
would break reproducibility.
**Signature.** Play App Signing treats the app key as the **upload key** only
and re-signs with Google's key. A Play install and an F-Droid/Codeberg install
therefore have **different signatures** and cannot update each other; switching
channels means uninstalling. Losing the app key is recoverable for Play (upload
key reset) but not for F-Droid.
**Signature — read this before assuming an update path exists.** Play App
Signing is mandatory for new apps, and Google generates and holds the app
signing key. The release keystore in CI is registered only as the **upload
key**: Play verifies uploads with it, then re-signs with Google's key before
delivery. Consequences, accepted deliberately:
**fastlane** is only the Play Developer API client (`supply`), never the build.
The `deploy` lane uploads the bundle plus `changelogs/<versionCode>.txt` for
every locale that has one. It never touches the listing; that is the `listing`
lane's job (see [Store listings](#store-listings-single-source-of-truth)).
- A Play install and an F-Droid install have **different signatures** and
**cannot update each other**. Switching channels requires uninstall +
reinstall, which loses nothing (all data lives in the system calendar
provider) but must be stated wherever both channels are advertised.
- Losing the upload key is **recoverable** — request an upload-key reset in the
Play Console. Losing the app key still is not, for F-Droid.
**Track.** Uploads go straight to `production` at full rollout, because the release
branch review is already the gate. Set the `PLAY_TRACK` variable (e.g.
`internal`) to stage, or `PLAY_RELEASE_STATUS=draft` to hold it unpublished.
**Build and signing are not fastlane's job.** Gradle does both, exactly as
before. fastlane appears only as the Play Developer API client (`supply`),
because the store listing already lives in `fastlane/metadata/android/` — the
same tree the official F-Droid repo harvests. One metadata source, two stores.
**First-time setup.**
1. Create the app in the Play Console with **English (United States) – en-US**
as the default language, so Play and F-Droid fall back to the same locale.
2. Upload the first AAB **by hand**; the API can't create an app's first
release. Enrol in Play App Signing on the way.
3. Create a Google Cloud service account, grant it release permissions for the
app in the Play Console, and store its JSON key as
`PLAY_SERVICE_ACCOUNT_JSON`.
4. Push the listing once with the `listing` lane (see
[Store listings](#store-listings-single-source-of-truth)), so every locale
that has a changelog also exists on Play before `deploy` sends its
"What's New".
5. Rehearse with `PLAY_DRY_RUN=true`, then clear it.
**What gets uploaded per release:** the AAB, plus the per-version "What's New"
from `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` — the
hand-written summary from step 3, which is why it must stay **under 500
characters**: Play rejects a longer one. Listing text is
**not** touched — an accidental overwrite of a live listing triggers a Play
policy review. Sync it deliberately with `bundle exec fastlane listing`.
**Screenshots and graphics are skipped**, because the committed assets satisfy
F-Droid but not Play:
| Asset | Committed | Play requires |
| --- | --- | --- |
| `phoneScreenshots/*.png` | 1280×2856, 32-bit RGBA | long edge ≤ 2× short edge (so ≤ 2560), 24-bit PNG, no alpha |
| `icon.png` | 512×512, 24-bit RGB | 512×512, 32-bit PNG |
| `featureGraphic.png` | *missing* | required, exactly 1024×500 |
Until those are fixed, Play's graphics are managed by hand in the console. Then
pass `images:true` to the `listing` lane.
**Track.** Uploads go straight to `production` at a full rollout
(`PLAY_RELEASE_STATUS=completed`). The merge to `main` is already the human
gate — a release only gets there after on-device review — so a second manual
promotion in the Play Console added delay without adding a decision. Set the
`PLAY_TRACK` repo variable (e.g. `internal`) to stage a release instead, or
`PLAY_RELEASE_STATUS=draft` to hold it unpublished.
One-time setup: the Codeberg repo's **Releases** unit must be enabled and a
`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope,
since it pushes the tag as well as creating the release) added to Gitea Actions.
### Manual re-sign / recovery
@@ -206,7 +255,7 @@ rotation or repo recovery without publishing a new app version.
## Two forges, one repo
**Codeberg (`jlmakiola/clockula`) is canonical** — git, issues, PRs, tags and
**Codeberg (`jlmakiola/clockula`) is canonical** for 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
@@ -221,14 +270,13 @@ first that exists; Gitea doesn't know `.forgejo/` at all:
| `.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
The line is drawn at **secrets, not at CI-vs-release**, which 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
PRs on Codeberg*: it talks to Codeberg's API instead of 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
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:
@@ -243,7 +291,7 @@ Two consequences worth remembering:
`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
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.
@@ -251,43 +299,94 @@ resolve.
## 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**
auto-generate a repo key (that would rotate the repo fingerprint and break every
user's pinned repo).
| Secret | Purpose |
| --- | --- |
| `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS` | **App** signing key — signs the APK. Losing it means existing installs can't be updated. |
| `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS` | **App** signing key. Signs the APK. Losing it means existing installs can't be updated. |
| `FDROID_KEYSTORE_BASE64` | **F-Droid repo** signing key (`keystore.p12`, base64). Signs the repo index. |
| `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. |
| `GITHUB_TOKEN` | Provided by Gitea Actions; used to create the release + attach assets. |
| `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. |
| `PLAY_SERVICE_ACCOUNT_JSON` | Google Cloud service-account key (full JSON) with Play Console access — uploads the AAB. If unset, the `play` job skips cleanly, which is the expected state for the whole pre-1.0 run. |
| `RENOVATE_TOKEN` | Codeberg bot-account token — repo read/write + PR scope on `jlmakiola/clockula`. Used only by `renovate.yml`. |
| `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. |
| `PLAY_SERVICE_ACCOUNT_JSON` | Google Cloud service-account key (full JSON) with Play Console release access. Uploads the AAB. If unset, the `play` job skips cleanly. |
| `RENOVATE_TOKEN` | Codeberg bot-account token with repo read/write + PR scope on `jlmakiola/clockula`. 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
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
live only in CI secrets and are reconstructed in-runner (nginx serves only
`repo/`).
---
### Variables (Gitea → repo Settings → Actions → Variables)
## F-Droid metadata (single source of truth)
Store-listing text lives in **`fastlane/metadata/android/<locale>/`** — the same
tree the official F-Droid repo harvests from source. At release time
`scripts/fastlane_to_fdroid_localized.sh` transforms it into the F-Droid repo's
"localized" layout, so there is no second copy to maintain. The app-level control
file (`Categories`/`License`/links) stays in
`fdroid-metadata/de.jeanlucmakiola.clockula.yml`. Per-version changelogs are
seeded into `fastlane/.../en-US/changelogs/<versionCode>.txt` by
`scripts/sync_changelog_to_fastlane.sh` (step 3 above) and carried across by the
transform.
| Variable | Default | Purpose |
| --- | --- | --- |
| `PLAY_TRACK` | `production` | Play track for the bundle; `internal`/`alpha`/`beta` to stage. |
| `PLAY_RELEASE_STATUS` | `completed` | `completed`, `draft`, `inProgress` or `halted`. |
| `PLAY_DRY_RUN` | `false` | `true` validates the Play edit and discards it. |
---
## Store listings (single source of truth)
Everything both stores show (title, short and full description, "What's New",
icon, feature graphic, screenshots) lives in
**`fastlane/metadata/android/<locale>/`**. Nothing is edited in the Play Console
or kept in a second copy for F-Droid.
```
fastlane/metadata/android/
en-US/ fallback of both stores; holds every graphic
title.txt ≤ 30 chars
short_description.txt ≤ 80 chars
full_description.txt ≤ 4000 chars
changelogs/<code>.txt ≤ 500 chars
images/
icon.png 512×512, 32-bit PNG
featureGraphic.png 1024×500, no alpha
phoneScreenshots/ 2–8, no alpha, 320–3840 px, long side ≤ 2× short
sevenInchScreenshots/ optional, same rules
tenInchScreenshots/ optional, same rules
en-GB/, de-DE/, pt-BR/, … text only; one per shipped values-* language
```
`en-US` is the fallback for both stores: F-Droid always uses it, and it is the
Play app's default language. So text and graphics committed there cover every
locale without its own. Graphics are committed **once, under `en-US`**; add them
to another locale only when they actually differ (e.g. localized screenshots).
A raw phone capture (1080×2400, 20:9) breaks Play's 2:1 aspect cap. Frame or
crop screenshots to at most 2:1, e.g. 1080×2160.
**Validation.** `scripts/check_store_listing.py` checks the whole tree against
Play's limits (the stricter of the two stores) and runs on every PR. Add
`--complete` to also require what a live Play listing needs: icon, feature
graphic, ≥ 2 phone screenshots and this version's `en-US` changelog.
**F-Droid.** The official repo harvests the tree from the tagged source. The
self-hosted repo gets it through `scripts/fastlane_to_fdroid_localized.sh` at
release time. The app-level control file (`Categories`/`License`/links) stays in
`fdroid-metadata/de.jeanlucmakiola.clockula.yml`.
**Play.** `fastlane/Fastfile`'s `listing` lane pushes text and graphics
(`supply`, only changed images are re-uploaded). It is run by hand, never by the
release pipeline, because overwriting a live listing triggers a Play review:
```sh
bundle install
SUPPLY_JSON_KEY=/path/to/play-service-account.json \
bundle exec fastlane listing dry_run:true # validate, discard
SUPPLY_JSON_KEY=/path/to/play-service-account.json \
bundle exec fastlane listing
```
The lane refuses to run unless `check_store_listing.py --complete` passes.
If the Play app's default language is ever not `en-US`, set
`PLAY_DEFAULT_LOCALE` and the lane copies the graphics into that locale.
## Crash deobfuscation
Each release attaches `mapping-<version>.txt.gz` (the R8 mapping) to its Gitea
+207 -207
View File
@@ -1,6 +1,6 @@
# Clockula — roadmap
The **status** view, and the work queue. Design rationale for every decision here
The status view and the work queue. Design rationale for every decision here
lives in [`PLAN.md`](PLAN.md); the shape of the code as it stands will live in
[`ARCHITECTURE.md`](ARCHITECTURE.md) from M2 onward.
@@ -13,16 +13,16 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started
✅ **M10 done.** Clockula now has a settings hub (theme, dynamic colour,
language, the alarm/timer/clock defaults every new alarm or timer inherits,
help), a documented, versioned JSON backup (`docs/BACKUP.md`) that exports and
restores alarms/timers/world clocks and settings through SAF — configuration
only, never ring state, never a timer's running anchors — as a whole-state
restores alarms/timers/world clocks and settings through SAF (configuration
only, never ring state, never a timer's running anchors) as a whole-state
replace the screen is explicit about before the user confirms, a "why might my
alarm not ring?" self-check that reads real device state (exact-alarm and
notification permissions, full-screen intent, battery optimisation, DND, alarm
volume, a known aggressive-OEM allowlist, and the system's own next alarm
compared against what Clockula computed) and deep-links to the exact settings
page for each, and three Glance home-screen widgets — next alarm, timer,
clock — that mirror existing state and redraw event-driven, on every write,
with no ticker of their own. **M11** (release readiness) is next.
page for each, and three Glance home-screen widgets (next alarm, timer,
clock) that mirror existing state and redraw event-driven, on every write,
with no ticker of their own. M11 (release readiness) is next.
---
@@ -30,12 +30,12 @@ with no ticker of their own. **M11** (release readiness) is next.
One milestone per iteration, auto-continuing. Each iteration:
1. Pick the **first milestone not marked ✅**.
2. Implement it **completely** — every checklist item under it.
1. Pick the first milestone not marked ✅.
2. Implement it completely: every checklist item under it.
3. Build, and run the tests the milestone names. A milestone is not done until
they pass.
4. **Commit** (floret-kit submodule first where it changed, then Clockula's
pointer bump — separate commits, granular, lowercase area prefix).
pointer bump; separate commits, granular, lowercase area prefix).
5. Mark the milestone ✅ here, update **Current state**, and commit that too.
6. Continue to the next milestone.
@@ -45,7 +45,7 @@ Rules that hold across every iteration:
(`PLAN.md` §0).
- **Never** add a foojay toolchain resolver, anywhere, not even in a comment.
- Commit each finished unit of work; never accumulate a WIP pile.
- If a milestone turns out to be wrong, say so and amend `PLAN.md` — do not
- If a milestone turns out to be wrong, say so and amend `PLAN.md`. Do not
silently build something else.
- `ARCHITECTURE.md` and `CHANGELOG.md` are updated as part of the milestone that
changes them, not in a later cleanup pass.
@@ -75,7 +75,7 @@ The project exists, builds, is themed, and ships itself.
`scripts/check_reproducible_release.sh` reports every invariant holding.
Two notes for later: the store listing and `fastlane` changelogs are written
but no screenshots exist yet (M11), and `docs/ARCHITECTURE.md` is still
unwritten by design — M2 writes it, once there is an architecture to
unwritten by design. M2 writes it, once there is an architecture to
describe.
### ✅ M1 — floret-kit: `core-prefs` + `core-di`
@@ -83,16 +83,16 @@ Pay the extraction the kit's roadmap has been waiting for a third app to trigger
- In the kit: `core-prefs` (`ThemeMode`, `dynamicColor`, typed DataStore wrapper,
`toEnum()`) and `core-di` (`@IoDispatcher` + provider), each with tests, module
docs, `CHANGELOG` and `ROADMAP`/`ARCHITECTURE` updates.
- Clockula consumes both. Migrating Calendula and Agendula is explicitly **out of
scope** — note it in the kit's roadmap as a follow-up.
- Clockula consumes both. Migrating Calendula and Agendula is explicitly out of
scope; note it in the kit's roadmap as a follow-up.
- **Done when:** the kit's tests pass, Clockula builds against the new modules,
and both repos are committed (submodule first, then the pointer bump).
✅ floret-kit 0.4.0. `core-prefs` (56 kit tests) gives the family a typed
`Pref<T>`/`PrefStore` over DataStore; reads degrade to the default instead of
throwing, and `flow()` is distinct-until-changed. The appearance key names are
frozen at what Calendula and Agendula already write, so the sibling migration —
a follow-up in the kit's roadmap, not here — needs no data migration.
frozen at what Calendula and Agendula already write, so the sibling migration
(a follow-up in the kit's roadmap, not here) needs no data migration.
`core-di` carries `hilt-compiler` itself, since an `@InstallIn` module only
reaches a consumer's component if its own library was processed; Clockula's
`assembleDebug` is the integration test for that binding. Clockula's theme now
@@ -114,19 +114,19 @@ The whole storage stack, headless. No UI.
✅ Four tables (`alarms`, `timers`, `world_clocks`, `stopwatch_laps`), schema
v1 exported to `app/schemas` so migrations can be tested from the beginning.
The seam that matters: the app talks to four repository interfaces speaking
domain types and Flows, and `ArchitectureRulesTest` fails the build if Room, an
entity or a query string ever appears above `data/`. Time is a parameter rather
than an ambient read — `WallClock` and `ElapsedRealtimeClock` are distinct
types, so a running timer anchors on the monotonic clock and keeps a wall-clock
value only as a post-reboot fallback, and the stopwatch keeps none at all. That
leaves one honest gap, written down rather than glossed: once a new boot's
uptime climbs past a stored anchor the reboot goes unnoticed, so closing it
needs a persisted boot id and M3's `BOOT_COMPLETED` receiver. Every
read-modify-write runs inside a DAO transaction, and the stopwatch run record
is written whole in one DataStore edit, so concurrent writers cannot swallow
each other's edits or persist a torn record. 151 JVM tests over fake DAOs and
injected fake clocks; the migration test is the only thing needing a device.
The app talks to four repository interfaces speaking domain types and Flows,
and `ArchitectureRulesTest` fails the build if Room, an entity or a query
string ever appears above `data/`. Time is a parameter rather than an ambient
read: `WallClock` and `ElapsedRealtimeClock` are distinct types, so a running
timer anchors on the monotonic clock and keeps a wall-clock value only as a
post-reboot fallback, and the stopwatch keeps none at all. That leaves one
gap: once a new boot's uptime climbs past a stored anchor the reboot goes
unnoticed, so closing it needs a persisted boot id and M3's `BOOT_COMPLETED`
receiver. Every read-modify-write runs inside a DAO transaction, and the
stopwatch run record is written whole in one DataStore edit, so concurrent
writers cannot swallow each other's edits or persist a torn record. 151 JVM
tests over fake DAOs and injected fake clocks; the migration test is the only
thing needing a device.
### ✅ M3 — Alarm engine
The hard part (`PLAN.md` §4), still headless apart from the ring screen's
@@ -140,13 +140,13 @@ plumbing.
- Ringing foreground service: audio focus, `USAGE_ALARM`, volume ramp, vibration,
ringtone URIs; survives process death and reboot mid-ring.
- Full-screen-intent notification + `canUseFullScreenIntent()` handling, degrading
to a high-priority heads-up notification that still rings — never to silence.
to a high-priority heads-up notification that still rings, never to silence.
- **Tests:** DST spring-forward and fall-back, every repeat configuration, skip,
snooze-vs-next-occurrence. These are the tests the app lives or dies on.
✅ 326 JVM tests, 172 of them new. The engine is a plain Kotlin class that
reaches Android through four interfaces it does not implement, so the
behaviour the app lives or dies on is pinned without an emulator —
behaviour the app lives or dies on is pinned without an emulator.
`ArchitectureRulesTest` fails the build if `android.*` ever appears in
`domain/`, `AlarmEngine.kt` or `system/`. Occurrences are generated by walking
local dates through `ZonedDateTime.of` rather than by adding 24 hours to an
@@ -155,43 +155,43 @@ plumbing.
fall-back night. Asserted for Berlin and New York and across all 128 repeat
masks. Two rules keep constant re-resolution honest: a two-minute grace window
so a late fire still counts, and a `handled_occurrence` watermark that can
suppress a re-fire but — because suppression also requires the candidate to be
at-or-before now — can never silence the future. Skip needed a watermark of its
suppress a re-fire but can never silence the future, because suppression also
requires the candidate to be at-or-before now. Skip needed a watermark of its
own, since a bare flag eats a second alarm once the skipped occurrence passes.
At most one alarm rings and a newly-firing one takes over, so closing a cycle
touches the shared ring and auto-silence slots only when that alarm is the one
ringing; every write to `alarm_states` runs under one mutex, because a cold
start triggered by the fire broadcast otherwise races its own reschedule pass.
The "never to silence" chain is a testable invariant, not a hope: full-screen
intent, else a heads-up notification that still rings, else the default
ringtone, else forced vibration. Schema v2 adds `alarm_states`, and the boot id
closes M2's blind spot for running timers **and** the stopwatch — a scope move
from M7, recorded in `ARCHITECTURE.md` §5. Knowingly open: the migration and
the other 12 instrumentation tests compile but have never run on hardware, as
no device was attached; an auto-silenced alarm is silently missed, with no
missed-alarm notification until someone asks for one.
The "never to silence" chain is a testable invariant: full-screen intent, else
a heads-up notification that still rings, else the default ringtone, else
forced vibration. Schema v2 adds `alarm_states`, and the boot id closes M2's
blind spot for running timers and the stopwatch, a scope move from M7
recorded in `ARCHITECTURE.md` §5. Knowingly open: the migration and the other
12 instrumentation tests compile but have never run on hardware, as no device
was attached; an auto-silenced alarm is silently missed, with no missed-alarm
notification until someone asks for one.
### ✅ M4 — App shell
- Navigation host, four tabs, M3 navigation bar, adaptive rail on wide layouts.
- The **live pill** (`PLAN.md` §9) — shared running-state surface, pause + stop
- The **live pill** (`PLAN.md` §9): shared running-state surface, pause + stop
from any tab.
- The ring screen UI: over-lockscreen, turn-screen-on, snooze/dismiss, optional
dismiss challenge that cannot strand the user.
- Motion from the kit's `identity` module; predictive back.
✅ 480 JVM tests, 154 of them new, and still **no Robolectric** — which had to
be earned rather than kept. Everything that decides something moved out of the
composables: `ShellNavigation` (what a tab tap does to the back stack, and
that back from a tab goes to Alarms while back from Alarms leaves the app),
✅ 480 JVM tests, 154 of them new, and still no Robolectric. Keeping it that
way meant moving everything that decides something out of the composables:
`ShellNavigation` (what a tab tap does to the back stack, and that back from a
tab goes to Alarms while back from Alarms leaves the app),
`LivePillSelector` (which of several running things the pill is about),
`ClockFormat`, and `ChallengeGate`. The adaptive part is
`NavigationSuiteScaffold` taking its own default — the short bar on a phone,
the wide rail on a tablet — rather than a hand-rolled width branch that would
`NavigationSuiteScaffold` taking its own default (the short bar on a phone,
the wide rail on a tablet) rather than a hand-rolled width branch that would
miss the tabletop posture. The pill shows for paused and expired timers too,
not only running ones: pausing from the pill must not make the pill disappear
under the thumb that pressed it. Its value comes from the snapshot, so a timer
that reaches zero flips to "finished" before M6's expiry write lands. The
challenge's escape hatch opens on three wrong answers **or** sixty seconds of
challenge's escape hatch opens on three wrong answers or sixty seconds of
ringing, for every challenge kind, asserted as one parameterised invariant;
snooze is never gated; a completed hold *is* the dismissal, and an
accessibility click completes the hold, because a challenge TalkBack cannot
@@ -200,7 +200,7 @@ plumbing.
top-level tab draws no back arrow that would pop nothing.
Knowingly open: M4 ships no in-app way to *start* a timer or the stopwatch, so
the live pill cannot be demonstrated by hand until M6/M7 — it is proven by its
the live pill cannot be demonstrated by hand until M6/M7. It is proven by its
unit tests and by an instrumentation test that seeds the repository directly,
and no debug button was added to paper over it. The nine new instrumentation
tests compile in the gate but have never run on hardware, as no device was
@@ -213,18 +213,18 @@ List, create, edit, delete, enable/disable. Time picker, repeat-day selector,
label, ringtone picker, vibrate, snooze settings, per-alarm overrides. Next-fire
line ("in 9h 12m") on each row. Built on `GroupedSurface`/`GroupedRow`.
✅ 624 JVM tests, 144 of them new, and **still no Robolectric** — kept for a
✅ 624 JVM tests, 144 of them new, and still no Robolectric, kept for a
whole *form*, which is where a screen usually reaches for one. Everything a
tap decides is a pure object (`AlarmListRows`, `NextFireFormat`,
`RepeatDaysSummary`, `AlarmDefaults`, `AlarmOverrides`, `RingtonePicker`,
`AudioSourcePolicy`, `AlarmRoutes`) or a ViewModel method tested over the
**real** `AlarmEngine`; three new `ArchitectureRulesTest` rules make that a
real `AlarmEngine`; three new `ArchitectureRulesTest` rules make that a
build fact rather than a habit.
The decisions worth knowing: the next-fire line comes from
`AlarmEngine.upcoming`, which grew a `refresh: Flow<Unit>` cadence parameter
so the countdown advances without a database write and the screen re-derives
none of the engine's time arithmetic; the list is ordered by **time of day**,
none of the engine's time arithmetic; the list is ordered by time of day,
not by next fire, so rows never swap under a thumb at 07:00; every per-alarm
override is an `OptionPicker` whose first row is "App default (On)", because
the stored value is ternary and a `Switch` cannot say "I have not chosen";
@@ -235,79 +235,79 @@ line ("in 9h 12m") on each row. Built on `GroupedSurface`/`GroupedRow`.
read-modify-write, so a one-shot alarm that disabled itself underneath an open
editor is not re-enabled by the next unrelated tap.
The correctness find: disabling or deleting a **ringing** alarm used to clear
`ringingSince` without stopping the ring service — whose auto-silence backstop
then disarms itself on its own null guard — so the alarm would have sounded
The correctness find: disabling or deleting a ringing alarm used to clear
`ringingSince` without stopping the ring service (whose auto-silence backstop
then disarms itself on its own null guard), so the alarm would have sounded
until the 15-minute wake-lock timeout. Both new ViewModels now dismiss through
the engine first, conditionally, so a merely snoozed alarm keeps its snooze.
floret-kit went to **0.6.0** for one change: `CollapsingScaffold` takes a
floret-kit went to 0.6.0 for one change: `CollapsingScaffold` takes a
`floatingActionButton` slot, forwarded to the underlying `Scaffold`, rather
than each app re-deriving the inset maths in a `Box`.
Knowingly open: no hand reordering of alarms (the time order answers it, and
`alarms` has no `sort_order` column), no multi-select or bulk delete, no undo
chip after a delete (traded for a confirmation that names the alarm), no
repeat presets, and no per-alarm auto-silence — still M10 at the earliest.
repeat presets, and no per-alarm auto-silence (still M10 at the earliest).
Editing the app-wide defaults every override row offers to follow is M10's
settings screen. The seven new instrumentation tests compile in the gate but
have **never run on hardware**, as no device was attached.
have never run on hardware, as no device was attached.
### ✅ M6 — Timers
Multiple concurrent timers, presets, labels, add/pause/reset/+1min. Foreground
service with notification controls, expiry ringing reusing M3's audio path.
Elapsed-realtime anchored.
✅ 843 JVM tests, 219 of them new, and **still no Robolectric** — kept for a
✅ 843 JVM tests, 219 of them new, and still no Robolectric, kept for a
second screen *and* for a foreground service, which is the other place a
project usually gives in. Every decision is a pure object (`TimerReadings`,
`TimerExpiry`, `TimerRingPolicy`, `TimerNotificationPolicy`, `TimerPresets`,
`TimerDurationEntry`, `TimerListRows`, `TimerRoutes`) or a method on the
engine or one of the two ViewModels, tested over the **real**
`TimerRepositoryImpl` and the **real** `TimerEngine`. `TimerService` holds no
policy and no state beyond one flag — even its two `delay` amounts come from
engine or one of the two ViewModels, tested over the real
`TimerRepositoryImpl` and the real `TimerEngine`. `TimerService` holds no
policy and no state beyond one flag; even its two `delay` amounts come from
the engine, so the timings are asserted in a JVM test. Three new
`ArchitectureRulesTest` rules pin it, one of which makes a *second*
`MediaPlayer` a build failure. The four new instrumentation tests bring the
compiled total to 32.
The decisions worth knowing: the expiry slot is **one** registration on the
`ELAPSED_REALTIME_WAKEUP` base — so a `TIME_SET` cannot warp a running timer,
The decisions worth knowing: the expiry slot is one registration on the
`ELAPSED_REALTIME_WAKEUP` base (so a `TIME_SET` cannot warp a running timer,
and a timer never populates `getNextAlarmClock()`, which belongs to the user's
next *alarm* — and its fire is an **idempotent sweep carrying no id**, so a
next *alarm*), and its fire is an idempotent sweep carrying no id, so a
late delivery expires everything due, an early one writes nothing, and the
anchors *are* the watermark; the service adds a second, independent trigger
for the same sweep, because neither has to be reliable alone; one
`systemExempted` foreground service covers the countdown and the ring, alive
on the live pill's own "active, not running" predicate, and the countdown
holds **no wake lock** (the alarm slot is what wakes the device); an alarm
ringing **wins the audio** and the timer's ring is *deferred, not lost*,
because the arbitration is a pure function of current state rather than an
event — so the reverse case is symmetric and free; two timers expiring
holds no wake lock (the alarm slot is what wakes the device); an alarm
ringing wins the audio and the timer's ring is deferred, because the
arbitration is a pure function of current state rather than an event, so the
reverse case is symmetric and free; two timers expiring
together share one ring session and one ten-minute window, and each is stopped
on its own, because silently resetting both destroys the information "which
one finished"; auto-silence stops the noise and leaves the row EXPIRED, which
is the load-bearing difference from an alarm; presets are app-wide in
DataStore with out-of-range values **dropped, never clamped**; and the
DataStore with out-of-range values dropped, never clamped; and the
notification's countdown is the platform chronometer, so the service posts
once per state change rather than once per second for forty-five minutes.
Two things the checklist did not name were changed anyway, and are recorded in
`ARCHITECTURE.md`: **"+1 min" on an expired timer now resumes it** with
`ARCHITECTURE.md`: "+1 min" on an expired timer now resumes it with
exactly the extra, in one transaction (M2 left it paused, written before any
UI existed and pinned by no test — and resuming is the only way to avoid
emitting an intermediate PAUSED frame to the pill and the row); and **the live
pill's timer actions now go through `TimerEngine`**, because after M6 there is
UI existed and pinned by no test; resuming is the only way to avoid
emitting an intermediate PAUSED frame to the pill and the row); and the live
pill's timer actions now go through `TimerEngine`, because after M6 there is
an AlarmManager registration and a service that have to move with the state.
The pill's contract is otherwise unchanged: M6 *extracted* its precedence into
`TimerReadings` so the pill, the notification and the ring share one ordering,
and `LivePillSelectorTest` passing **unmodified** is the proof.
and `LivePillSelectorTest` passing unmodified is the proof.
No schema change (the database stays at v2; the ring session's one volatile
fact is a DataStore record, which is `PLAN.md` §5's own record-versus-rows
split and keeps it out of M10's backup), **no new permission** (a timer never
takes over the screen — no full-screen intent, no ring activity), and **no
floret-kit change**: the kit earned `CollapsingScaffold`'s
split and keeps it out of M10's backup), no new permission (a timer never
takes over the screen: no full-screen intent, no ring activity), and no
floret-kit change: the kit earned `CollapsingScaffold`'s
`floatingActionButton` slot at 0.6.0 for exactly this shape of tab, and M6
consumes it.
@@ -318,85 +318,85 @@ Elapsed-realtime anchored.
"stop all" when several timers are expired, no undo after a delete (traded for
a confirmation, as with alarms), no ramp on a timer's ring, no presets
management screen and no editing of `default_timer_duration_millis` or
`default_timer_ringtone_uri` — M10's settings screen. `TimerSnapshot.anchorIsStale`
`default_timer_ringtone_uri` (M10's settings screen). `TimerSnapshot.anchorIsStale`
is still not surfaced in the UI. Of `AlarmClock`'s intent contract only the
in-process hook `ShellNavigation.tabForAction` exists, wired to one action;
M9 owns the rest. The four new instrumentation tests compile in the gate but
have **never run on hardware**, as no device was attached — the same caveat
M5's seven carry.
have never run on hardware, as no device was attached; M5's seven carry the
same caveat.
Two cases in the new suite are left **red** and are a design conversation
rather than a behaviour gap; both are defects in the test, not in the code:
Two cases in the new suite are left red. Both are defects in the test, not in
the code, so they are a design conversation rather than a behaviour gap:
`TimerIntentsTest` §5.17 #3 asserts `containsNoDuplicates` over a list that
contains the *same* `requestCodeFor(REQUEST_PAUSE, 1L)` call twice by
construction, which no deterministic function can satisfy; and `TimerPrefsTest`
§5.10 #11 opens a second DataStore over a live file in the same scope, which
DataStore refuses by design — as `SettingsPrefsTest` itself documents.
DataStore refuses by design, as `SettingsPrefsTest` itself documents.
### ✅ M7 — Stopwatch
Start/stop/reset, laps with splits and cumulative times, best/worst lap emphasis.
Foreground service so it survives backgrounding; laps persist across process
death. This is where the big-readout typography gets settled for the whole app.
The post-reboot repair is **not** here — M3 took it (`ARCHITECTURE.md` §5);
The post-reboot repair is not here: M3 took it (`ARCHITECTURE.md` §5), and
M7 owns only how the paused result is presented.
✅ 968 JVM tests, 125 of them new, and **still no Robolectric** — kept now for
✅ 968 JVM tests, 125 of them new, and still no Robolectric, kept now for
a third screen and a third service. Everything the tab decides is a pure
object (`StopwatchFormat`, `StopwatchReadings`, `LapStats`, `StopwatchLaps`,
`StopwatchNotificationPolicy`, `StopwatchRows`) or a method on the engine or
the ViewModel, tested over the **real** `StopwatchRepositoryImpl` across the
the ViewModel, tested over the real `StopwatchRepositoryImpl` across the
real `appendLap` transaction and a real DataStore under a `@TempDir`.
`StopwatchService` holds no policy and **no state at all** — not even the
timers' one `alreadyAlerted` flag, because nothing here ever alerts — and it
`StopwatchService` holds no policy and no state at all (not even the
timers' one `alreadyAlerted` flag, because nothing here ever alerts), and it
has no `delay`. Two new `ArchitectureRulesTest` rules and two extended; the
four new instrumentation tests bring the compiled total to 36.
The decisions worth knowing: the foreground service is **`specialUse`**, not
`systemExempted`, because a stopwatch is not alarm functionality — it
The decisions worth knowing: the foreground service is `specialUse`, not
`systemExempted`, because a stopwatch is not alarm functionality. It
schedules nothing, wakes nothing and rings nothing, and claiming the type
would have the app tell the platform something untrue; it holds **no wake
lock, ever**, since the elapsed value is derived from a persisted monotonic
anchor at read time; the notification is the **platform chronometer counting
up**, with its base `now - elapsed` **derived at post time and never stored**,
would have the app tell the platform something untrue. It holds no wake
lock, ever, since the elapsed value is derived from a persisted monotonic
anchor at read time; the notification is the platform chronometer counting
up, with its base `now - elapsed` derived at post time and never stored,
which is how the run record keeps its "no wall-clock field whatsoever" rule;
its channel is `IMPORTANCE_LOW` and silent, because nothing here ever
finishes, so there is no alert-once rule and no session window anywhere in the
path; the readout is hundredths and it is **two strings**, so the
fastest-changing glyphs are not the loudest ones; best and worst need **three**
path; the readout is hundredths and it is two strings, so the
fastest-changing glyphs are not the loudest ones; best and worst need three
laps, ties go to the earliest, and all-equal means neither; laps are capped at
**999**, guarded in the engine over one `COUNT(*)` so appending never
materialises the rows; and the tab ticks at ~60 Hz **only while running**, so
999, guarded in the engine over one `COUNT(*)` so appending never
materialises the rows; and the tab ticks at ~60 Hz only while running, so
a paused or idle stopwatch costs nothing.
Three things the checklist did not name were changed anyway, and are recorded
in `ARCHITECTURE.md`: **the live pill's stopwatch actions now go through
`StopwatchEngine`** (M6 recorded this as knowingly open), with the pill's own
contract unchanged and `LivePillSelectorTest` passing **unmodified** as the
proof; **`LivePillSelector` was re-pointed at a new pure `StopwatchReadings`**,
in `ARCHITECTURE.md`: the live pill's stopwatch actions now go through
`StopwatchEngine` (M6 recorded this as knowingly open), with the pill's own
contract unchanged and `LivePillSelectorTest` passing unmodified as the
proof; `LivePillSelector` was re-pointed at a new pure `StopwatchReadings`,
the same extraction M6 made for timers, so the pill, the shade and the tab
cannot answer "what does this read right now" three different ways; and
**`ui/theme/Type.kt` stopped being `Typography()`** — tabular figures on the
`ui/theme/Type.kt` stopped being `Typography()`: tabular figures on the
display, headline and title roles, app-wide, with no call-site change
anywhere, plus `ClockulaReadoutDefaults` naming the three roles a readout uses.
The correctness find: a run that met a reboot mid-lap had `accumulated = 0`
while its laps still recorded forty seconds, so the honest reading is
**floored at the last lap's total** as well as at zero — a readout sitting
floored at the last lap's total as well as at zero. A readout sitting
below a lap the same run printed is not one anyone can believe, and the laps
are real measurements that are never deleted to tidy the arithmetic. Recorded
in `ARCHITECTURE.md` §5. The review found the other half of it: the floor has
to be **banked, not only displayed** — resuming writes
to be banked as well as displayed. Resuming writes
`accumulated = max(accumulated, lastLapCumulative)`, and pausing and lapping
bank the same floored reading — or the record ticks from below its own last
lap, the shade's free-running chronometer counts while the tab sits frozen,
and the next lap records a cumulative below the previous one. The same review
pass moved the engine's verbs onto **the reading's** mode rather than the
stored row's, so a stale record's Resume button resumes instead of hitting a
silent no-op. `pauseAfterReboot` itself is still untouched.
bank the same floored reading; otherwise the record ticks from below its own
last lap, the shade's free-running chronometer counts while the tab sits
frozen, and the next lap records a cumulative below the previous one. The
same review pass moved the engine's verbs onto the reading's mode rather than
the stored row's, so a stale record's Resume button resumes instead of hitting
a silent no-op. `pauseAfterReboot` itself is still untouched.
No schema change (the database stays at v2; `LapDao` gains one `COUNT(*)` and
nothing else), **one** new permission — `FOREGROUND_SERVICE_SPECIAL_USE`, the
one that names what the service actually does — and **no floret-kit change**:
nothing else), one new permission (`FOREGROUND_SERVICE_SPECIAL_USE`, the
one that names what the service actually does), and no floret-kit change:
the kit stays at 0.6.0, untouched.
Knowingly open: no lap export, sharing, deletion or editing, and no "previous
@@ -409,86 +409,86 @@ M7 owns only how the paused result is presented.
is still not surfaced, the same answer M6 gave for timers. Of `AlarmClock`'s
intent contract only the in-process hook `ShellNavigation.tabForAction` grew a
second action; M9 owns the rest. The four new instrumentation tests compile in
the gate but have **never run on hardware**, as no device was attached — the
same caveat M5's seven and M6's four carry.
the gate but have never run on hardware, as no device was attached; M5's
seven and M6's four carry the same caveat.
### ✅ M8 — World clock
IANA zone list with search, ICU-localised city and zone display names, offset and
day-difference relative to home, reorder, home-zone handling. The analog face
built from `MaterialShapes` / `androidx.graphics.shapes` — the app's one
built from `MaterialShapes` / `androidx.graphics.shapes` is the app's one
deliberate showpiece.
✅ 1 139 JVM tests, 171 of them new, and **still no Robolectric** — kept now
for a fourth screen, and this was the milestone most tempted to break it,
✅ 1 139 JVM tests, 171 of them new, and still no Robolectric, kept now
for a fourth screen. This was the milestone most tempted to break it,
because ICU and `Canvas` both look like they need a device. They do not:
everything ICU answers is behind `ZoneNames` (faked, with per-method call
counters and a `failEverything` flag), everything the face draws is
`DrawScope` calls over `AnalogFace`'s angles and `DayNight`'s fraction — two
pure objects asserted at every minute of the day without a pixel — and
`DrawScope` calls over `AnalogFace`'s angles and `DayNight`'s fraction (two
pure objects asserted at every minute of the day without a pixel), and
everything the list and the picker decide is `WorldClockRows`,
`ZonePickerRows`, `ZoneSearch`, `ZoneComparisons`, `ZoneCatalog` and
`WorldClocks`. Every write is a `WorldClockViewModel` method over the **real**
`WorldClocks`. Every write is a `WorldClockViewModel` method over the real
`WorldClockRepositoryImpl` across `FakeWorldClockDao` (so `addIfAbsent` and
`reorder`'s real transactions run) and a real DataStore under a `@TempDir`.
Three new `ArchitectureRulesTest` rules; the four new instrumentation tests
bring the compiled total to 40.
The decisions worth knowing: **there is no engine and must not be one** — a
The decisions worth knowing: there is no engine and must not be one, because a
world clock owns no AlarmManager slot, no service and no ring session, so a
fourth engine would be three empty seams and a `Mutex` guarding nothing; the
tab reads the **wall clock**, and re-reads both the instant and the device
zone on **every tick**, which is what makes `TIME_SET` and `TIMEZONE_CHANGED`
free (no new receiver branch); the zone **ids** stay pure (through a new
`ZoneProvider.available()`) while only the **names** cross the ICU seam, which
tab reads the wall clock, and re-reads both the instant and the device
zone on every tick, which is what makes `TIME_SET` and `TIMEZONE_CHANGED`
free (no new receiver branch); the zone ids stay pure (through a new
`ZoneProvider.available()`) while only the names cross the ICU seam, which
is one file, `runCatching`-guarded end to end and pinned there by a build
rule; the catalog keeps only region-based ids that are **their own canonical
id** — so the tzdata's own canonicalisation drops Asia/Calcutta in favour of
Asia/Kolkata and no table of ours can rot — unless the canonical id is not on
rule; the catalog keeps only region-based ids that are their own canonical
id (so the tzdata's own canonicalisation drops Asia/Calcutta in favour of
Asia/Kolkata and no table of ours can rot), unless the canonical id is not on
the device at all, since ICU and tzdata are separately updatable APEX modules
that can disagree, plus `UTC` by name; search is
**word-prefix over folded text**, so "erl" deliberately does not find Berlin;
the offset and day are computed **at the instant** from both zones' rules, the
word-prefix over folded text, so "erl" deliberately does not find Berlin;
the offset and day are computed at the instant from both zones' rules, the
same discipline `AlarmOccurrences` uses, so Berlin↔Sydney is ten hours in
January and eight in July; labels are **data, not strings**, so Kathmandu is
January and eight in July; labels are data, not strings, so Kathmandu is
`Ahead(4, 45)` rather than a signed 285 for a translator to interpret; the
cadence is **200 ms** with the state truncated to the second (face) and the
cadence is 200 ms with the state truncated to the second (face) and the
minute (rows) and `distinctUntilChanged` over it; the list is capped at
**24**, with the cap spelled out on the screen once it is reached, because
24, with the cap spelled out on the screen once it is reached, because
M3's `ExtendedFloatingActionButton` has no `enabled` to dim; and
reduce-motion snaps the morph but **never stops the second hand**,
reduce-motion snaps the morph but never stops the second hand,
because a clock whose second hand stops is a broken clock.
Three things outside the checklist were changed anyway, and are recorded in
`ARCHITECTURE.md`: **`ZoneProvider` gained `available()`**, because
`ARCHITECTURE.md`: `ZoneProvider` gained `available()`, because
`ZoneId.getAvailableZoneIds()` at a call site is the same ambient read
`PLAN.md` §5 bans for `now()`; **`ui/common/AlarmTimeFormatter.kt` gained
`rememberLocalTimeFormatter`** with the existing function delegating to it, so
`PLAN.md` §5 bans for `now()`; `ui/common/AlarmTimeFormatter.kt` gained
`rememberLocalTimeFormatter` with the existing function delegating to it, so
M5's "one 12/24-hour formatter" survives a fourth caller instead of growing a
second answer; and **`ShellDefaults.EmptyTabScreen` was deleted**, its own
KDoc having said it existed only until each of M5–M8 replaced its own tab —
second answer; and `ShellDefaults.EmptyTabScreen` was deleted, its own
KDoc having said it existed only until each of M5–M8 replaced its own tab;
the world clock was its last caller.
No schema change at all (the database stays at v2; `WorldClockEntity`, its
DAO, its mapper and its repository are not edited), **no new permission and no
manifest entry**, **one** new dependency coordinate —
`androidx.graphics:graphics-shapes:1.0.1`, pinned to the version that already
resolved transitively, because M8 imports that package directly — and **no
floret-kit change**: the kit stays at 0.6.0, untouched, with
DAO, its mapper and its repository are not edited), no new permission and no
manifest entry, one new dependency coordinate
(`androidx.graphics:graphics-shapes:1.0.1`, pinned to the version that already
resolved transitively, because M8 imports that package directly), and no
floret-kit change: the kit stays at 0.6.0, untouched, with
`ReorderableColumn`, `GroupedSurface`, `FullScreenPicker`, `InlineTextField`
and `CollapsingScaffold`'s FAB slot already covering the screen.
Knowingly open: no rename UI for `world_clocks.label` — the column is honoured
on read and M10's backup import will carry labels; no automatic "home" row
Knowingly open: no rename UI for `world_clocks.label` (the column is honoured
on read and M10's backup import will carry labels); no automatic "home" row
while travelling (a separate feature with its own preference, and the hero
face already follows the device); no per-city detail screen, map,
sunrise/sunset or day-length bar — `DayNight` is a stated convention for a
shape morph, not an ephemeris; no live times or long zone names in the picker;
sunrise/sunset or day-length bar (`DayNight` is a stated convention for a
shape morph, not an ephemeris); no live times or long zone names in the picker;
no seeded data, so a fresh install really has an empty list; no reordering by
anything but `sort_order`; and no `MaterialShapes` on the ring screen or timer
progress, which `PLAN.md` §8 also sanctions — the build rule makes widening
progress, which `PLAN.md` §8 also sanctions; the build rule makes widening
that allowlist a deliberate edit. The four new instrumentation tests compile
in the gate but have **never run on hardware**, as no device was attached —
the same caveat M5's seven, M6's four and M7's four carry.
in the gate but have never run on hardware, as no device was attached;
M5's seven, M6's four and M7's four carry the same caveat.
### ✅ M9 — System interop
The full `android.provider.AlarmClock` contract (`PLAN.md` §6): `SET_ALARM`,
@@ -496,103 +496,103 @@ The full `android.provider.AlarmClock` contract (`PLAN.md` §6): `SET_ALARM`,
next-alarm publishing. Hostile-input validation at the intent boundary.
- **Tests:** extra validation, including malformed and out-of-range input.
✅ 1 420 JVM tests, 281 of them new, and **still no Robolectric** — kept now
✅ 1 420 JVM tests, 281 of them new, and still no Robolectric, kept now
for a *boundary*, which is the last place a project usually gives in, because
"it is an `Intent`" sounds like it needs a device. It does not: the door is
split so that everything which decides is pure (`AlarmClockRequests`,
`AlarmMatching`, `IntentExtras`, `InteropText`, `InteropDeepLinks`) and the
Android half is two policy-free files — a `Bundle` reader and an `Intent`
builder. `AlarmClockHandler` orchestrates with **no `android.*` import**, as
all three engines do, and is tested over both **real** engines through a new
`InteropHarness` — deliberately *not* a composition of the existing harnesses,
since each builds a DataStore over the same file name and DataStore refuses a
second instance over a live one. Four new `ArchitectureRulesTest` rules and a
new `ManifestRulesTest`, because the manifest is where this milestone could be
silently wrong and none of it fails a compiler. The five new instrumentation
tests bring the compiled total to 45.
Android half is two policy-free files: a `Bundle` reader and an `Intent`
builder. `AlarmClockHandler` orchestrates with no `android.*` import, as
all three engines do, and is tested over both real engines through a new
`InteropHarness`. It is deliberately not a composition of the existing
harnesses, since each builds a DataStore over the same file name and DataStore
refuses a second instance over a live one. Four new `ArchitectureRulesTest`
rules and a new `ManifestRulesTest`, because the manifest is where this
milestone could be silently wrong and none of it fails a compiler. The five
new instrumentation tests bring the compiled total to 45.
`ACTION_DISMISS_TIMER` — the seventh action, named neither in this checklist
nor in `PLAN.md` §6's table — was built anyway: "the full contract" is the
`ACTION_DISMISS_TIMER`, the seventh action, named neither in this checklist
nor in `PLAN.md` §6's table, was built anyway: "the full contract" is the
milestone's own first sentence, and shipping six of seven and calling it full
would be the kind of quiet gap this loop exists to avoid.
The decisions worth knowing: **one range rule settles every extra** — a value
that decides when something will ring in the *future* is **dropped** out of
range, a value that modifies what the user is doing *now* is **clamped**, so
The decisions worth knowing: one range rule settles every extra. A value
that decides when something will ring in the *future* is dropped out of
range, and a value that modifies what the user is doing *now* is clamped, so
hour 25 never becomes an enabled 23:00 alarm while a 1 000-minute snooze
becomes the 60 minutes `ClockPrefs` would have allowed; **a `Bundle` is
modelled honestly** as `Map<String, Any?>` with four *total* accessors,
becomes the 60 minutes `ClockPrefs` would have allowed; a `Bundle` is
modelled honestly as `Map<String, Any?>` with four *total* accessors,
because hostile input is an `Int` where a `String` was documented far more
often than it is an out-of-range number, and the platform's typed getters
cannot tell "absent" from "wrong type"; **`SKIP_UI` never means skip
validation** — an incomplete `SET_ALARM` creates the row from the extras that
cannot tell "absent" from "wrong type"; `SKIP_UI` never means skip
validation, so an incomplete `SET_ALARM` creates the row from the extras that
*were* valid, at the next whole hour, and lands the user in its editor, since
M5 locked that the editor edits a persisted row and nothing is ever written
and hidden; **`ALARM_SEARCH_MODE_TIME` matches exactly, never "nearest"**, and
and hidden; `ALARM_SEARCH_MODE_TIME` matches exactly, never "nearest", and
an ambiguous 0..12 hour with no `IS_PM` carries *both* readings as candidates
so the question only reaches the user when the data cannot settle it;
**`ALARM_SEARCH_MODE_ALL` never asks** — taken literally the contract's "show
the results" would make the mode useless — while every other search with two
`ALARM_SEARCH_MODE_ALL` never asks (taken literally, the contract's "show
the results" would make the mode useless), while every other search with two
or more matches opens the milestone's one new surface, an M3 basic dialog with
a `ListItem` list where tapping a row *is* the answer.
**A correctness find, fixed here:** the `AlarmClockInfo` **`showIntent`** — the
A correctness find, fixed here: the `AlarmClockInfo` `showIntent` (the
thing the system opens when the user taps the status-bar alarm icon or the
lockscreen's next-alarm line — pointed at the *ring screen* with
lockscreen's next-alarm line) pointed at the *ring screen* with
`CLEAR_TASK`, so tapping "my next alarm" opened a ring screen for an alarm
that was not ringing, which resolved to `Finished` and closed itself. It now
opens `MainActivity` on the Alarms tab, and a build rule fails the build if
`AndroidAlarmScheduler` ever names `AlarmRingActivity` again. That intent is
literally what "next-alarm publishing" means to a user.
what "next-alarm publishing" means to a user.
**The schema went to v3**: `delete_after_use` on `alarms` and on `timers`, two
The schema went to v3: `delete_after_use` on `alarms` and on `timers`, two
`ALTER TABLE`s in `MIGRATION_2_3`, `3.json` exported and committed, `1.json`
and `2.json` untouched. It is not optional polish — the contract says in two
places that a `SKIP_UI` alarm or timer should be removed once dismissed, and
without it a daily "wake me at 6:30" turns the Alarms tab into a graveyard.
and `2.json` untouched. The contract says in two places that a `SKIP_UI`
alarm or timer should be removed once dismissed, and without the column a
daily "wake me at 6:30" turns the Alarms tab into a graveyard.
The flag is set only on the `SKIP_UI` create path and honoured inside the two
engines, under their locks.
Four things outside the checklist were changed anyway, and are recorded in
`ARCHITECTURE.md`: **`AlarmEngine` gained `dismissUpcoming(id)` and a
`minutesOverride` on `snooze`**, because both move the ring slot, the
`ARCHITECTURE.md`: `AlarmEngine` gained `dismissUpcoming(id)` and a
`minutesOverride` on `snooze`, because both move the ring slot, the
auto-silence backstop and the next-alarm registration, and a handler calling
three repositories in a row would race a fire broadcast; **`TimerEngine` gained
`dismissAllExpired()` and `dismissExpired(id)`** — the first is the "stop all"
three repositories in a row would race a fire broadcast; `TimerEngine` gained
`dismissAllExpired()` and `dismissExpired(id)` (the first is the "stop all"
the UI still deliberately does not offer, because that rule was about a thumb
on a ring and a programmatic request has stated exactly what it is discarding;
**`domain/text/TextFolding` was extracted** and `ZoneSearch` delegates to it,
with `ZoneSearchTest` passing unmodified as the proof; and **the shell's
`openTab` became `openRequest: ShellRequest?`**, since three of the contract's
on a ring and a programmatic request has stated exactly what it is discarding);
`domain/text/TextFolding` was extracted and `ZoneSearch` delegates to it,
with `ZoneSearchTest` passing unmodified as the proof; and the shell's
`openTab` became `openRequest: ShellRequest?`, since three of the contract's
outcomes are not "select a tab".
**No new `<uses-permission>`**: the one new permission string is
`android:permission` *on the door*, a requirement on the caller. **No new
dependency coordinate** and **no floret-kit change** — the kit stays at 0.6.0,
No new `<uses-permission>`: the one new permission string is
`android:permission` *on the door*, a requirement on the caller. No new
dependency coordinate and no floret-kit change; the kit stays at 0.6.0,
untouched, because the one new surface is material3's own `AlertDialog`.
Knowingly open: no voice-interaction follow-on flows
(`VoiceInteractor.CompleteVoiceRequest`/`PickOptionRequest`), so
`android.intent.category.VOICE` is deliberately not declared and Clockula
accepts a `clockula://` deeplink while publishing none; no "most closely
matched" time search; no toast on a `SKIP_UI` write — the status-bar icon is
the platform's own receipt; no `delete_after_use` surface in either editor, so
matched" time search; no toast on a `SKIP_UI` write (the status-bar icon is
the platform's own receipt); no `delete_after_use` surface in either editor, so
an assistant-set alarm the user edits stays transient, as it does in Google
Clock; no `EXTRA_MESSAGE` carried into the timer setup panel when no length
was given; and `AlarmScheduler.systemNextAlarm()` is still caller-less — it
was given; and `AlarmScheduler.systemNextAlarm()` is still caller-less, since it
belongs to M10's self-check screen. The five new instrumentation tests and the
two new migration cases compile in the gate but have **never run on
hardware**, as no device was attached — the same caveat M5's seven, M6's four,
M7's four and M8's four carry.
two new migration cases compile in the gate but have never run on
hardware, as no device was attached; M5's seven, M6's four, M7's four and
M8's four carry the same caveat.
### ✅ M10 — Settings, backup, the self-check, and widgets
- Settings composed from kit components: theme, dynamic colour, language,
alarm/timer/clock defaults, about + crash reporting.
- JSON backup export/import via SAF (`PLAN.md` §7), schema documented in
`docs/BACKUP.md`.
- The **"why might my alarm not ring?"** self-check screen (`PLAN.md` §4) —
- The "why might my alarm not ring?" self-check screen (`PLAN.md` §4):
real device state, deep links to the exact settings pages.
- Three Glance home-screen widgets — next alarm, timer, clock (`PLAN.md` §9).
- Three Glance home-screen widgets: next alarm, timer, clock (`PLAN.md` §9).
- **Tests:** backup round-trip and unknown-field tolerance; self-check rule
logic per permission/state combination; widget model selection/formatting.
@@ -608,13 +608,13 @@ reliability soak across a few real nights. Then 1.0.0.
Deferred deliberately, in rough priority order:
- **Dock / screensaver mode** — a `DreamService` full-screen clock. Cheapest big
- **Dock / screensaver mode**: a `DreamService` full-screen clock. Cheapest big
win, and the surface people look at most.
- **Quick Settings tile** — timer control and next alarm from the shade.
- **Bedtime / sleep schedule** — wind-down and wake, with DND handoff. A feature
- **Quick Settings tile**: timer control and next alarm from the shade.
- **Bedtime / sleep schedule**: wind-down and wake, with DND handoff. A feature
in its own right, realistically v2.
- **`core-notification` in floret-kit** — revisit once Clockula's real
- **`core-notification` in floret-kit**: revisit once Clockula's real
notification surface is known (`PLAN.md` §3).
- **Migrate Calendula and Agendula onto `core-prefs` + `core-di`** — tracked in
- **Migrate Calendula and Agendula onto `core-prefs` + `core-di`**: tracked in
the kit's roadmap, not here.
- **Wear OS companion** — unscoped.
- **Wear OS companion**: unscoped.
+1 -4
View File
@@ -1,7 +1,4 @@
# fastlane is used in this repo ONLY as a Google Play Developer API client
# (see fastlane/Fastfile). It never builds and never signs.
# Reconstructed in CI from the PLAY_SERVICE_ACCOUNT_JSON secret; gitignored.
# fastlane is only the Play Developer API client here (see Fastfile).
json_key_file(ENV["SUPPLY_JSON_KEY"] || "play-service-account.json")
package_name("de.jeanlucmakiola.clockula")
+49 -61
View File
@@ -1,87 +1,75 @@
# Google Play publishing only.
# Google Play: release upload and listing sync.
#
# Building and signing are deliberately NOT fastlane's job: release.yaml drives
# Gradle directly so the release build stays F-Droid-reproducible (`vcsInfo`,
# `dependenciesInfo` and the AGP metadata block are disabled in
# app/build.gradle.kts and guarded by scripts/check_reproducible_release.sh).
# Interposing fastlane there would add a layer that can inject Gradle flags into
# the one build whose byte-for-byte output is verified by a third party.
#
# What fastlane IS here for is `supply` — the Play Developer API client — because
# the store listing already lives in fastlane/metadata/android, which the
# official F-Droid repo harvests from the tagged source tree. One metadata tree,
# two stores.
# fastlane/metadata/android is the single source for both stores: the official
# F-Droid repo harvests it from the tagged tree, the self-hosted repo gets it
# through scripts/fastlane_to_fdroid_localized.sh, and this pushes it to Play.
# Building and signing stay with Gradle so the release build remains
# F-Droid-reproducible.
require "fileutils"
require "tmpdir"
default_platform(:android)
# fastlane runs a lane body with the working directory set to `fastlane/`, not
# the project root, so a relative artifact path from the caller has to be
# resolved explicitly. Absolute paths pass through untouched.
def project_path(path)
File.expand_path(path, File.expand_path("..", FastlaneCore::FastlaneFolder.path || "."))
end
ROOT = File.expand_path("..", __dir__)
METADATA = File.join(ROOT, "fastlane/metadata/android")
# Graphics are committed once, under en-US (F-Droid's fallback). Play falls back
# to its *default language*, en-US for this app; if that ever differs
# (PLAY_DEFAULT_LOCALE), the listing lane copies the graphics across.
IMAGE_LOCALE = "en-US"
platform :android do
desc "Upload an already-built, already-signed AAB to Play"
lane :deploy do |options|
aab = project_path(options[:aab] || "app/build/outputs/bundle/release/app-release.aab")
aab = File.expand_path(options[:aab] || "app/build/outputs/bundle/release/app-release.aab", ROOT)
UI.user_error!("AAB not found at #{aab}") unless File.exist?(aab)
supply(
aab: aab,
metadata_path: METADATA,
track: options[:track] || "production",
release_status: options[:release_status] || "completed",
# The APK belongs to F-Droid and the Codeberg download; Play only ever
# gets the bundle. Never let the uploader reach for the other artifact.
# The APK belongs to F-Droid and the Codeberg download.
skip_upload_apk: true,
# Listing text is synced deliberately via the `listing` lane, not on every
# release. Overwriting a live listing by accident triggers a Play policy
# review, which is not a quick revert.
# The listing goes through the `listing` lane, deliberately; overwriting
# a live listing by accident triggers a Play review.
skip_upload_metadata: true,
# "What's New" DOES track every release.
# scripts/sync_changelog_to_fastlane.sh writes
# fastlane/metadata/android/en-US/changelogs/<versionCode>.txt from
# CHANGELOG.md, so Play shows exactly the text F-Droid does.
skip_upload_changelogs: false,
# See the `listing` lane for why the committed images can't go to Play yet.
skip_upload_images: true,
skip_upload_screenshots: true,
# Dry run: validate the edit against the API and roll it back instead of
# committing it. Used for the first end-to-end rehearsal.
# "What's New" tracks every release, from every locale's
# changelogs/<versionCode>.txt — the same files F-Droid shows.
skip_upload_changelogs: false,
validate_only: options[:dry_run].to_s == "true",
)
end
desc "Sync the store listing text (and, once the assets qualify, the images)"
desc "Push listing text and graphics from the repo to Play"
desc " dry_run:true validate against the API and discard the edit"
lane :listing do |options|
# Deliberate and manual: `bundle exec fastlane listing`. Not wired into the
# release pipeline.
#
# Images stay off by default because the committed assets do not satisfy
# Play today, even though F-Droid accepts all of them:
#
# * phoneScreenshots are 1280x2856. Play caps the long edge at twice the
# short edge (2560 here), so every screenshot is rejected.
# * those PNGs are 32-bit RGBA. Play wants 24-bit PNG, no alpha.
# * icon.png is 512x512 but 24-bit RGB. Play's icon wants 32-bit PNG.
# * there is no featureGraphic.png. Play requires one, exactly 1024x500.
#
# Until that is fixed, the Play listing's graphics are managed by hand in the
# console. Pass images:true once the assets qualify.
upload_images = options[:images].to_s == "true"
sh("python3", File.join(ROOT, "scripts/check_store_listing.py"), "--complete")
supply(
skip_upload_aab: true,
skip_upload_apk: true,
skip_upload_metadata: false,
skip_upload_changelogs: true,
skip_upload_images: !upload_images,
skip_upload_screenshots: !upload_images,
)
default_locale = ENV["PLAY_DEFAULT_LOCALE"] || IMAGE_LOCALE
Dir.mktmpdir("play-listing") do |staged|
FileUtils.cp_r("#{METADATA}/.", staged)
src = File.join(staged, IMAGE_LOCALE, "images")
dst = File.join(staged, default_locale, "images")
FileUtils.cp_r(src, dst) if default_locale != IMAGE_LOCALE && !File.exist?(dst)
supply(
metadata_path: staged,
skip_upload_aab: true,
skip_upload_apk: true,
skip_upload_metadata: false,
# "What's New" belongs to a release on a track, not to the listing.
skip_upload_changelogs: true,
skip_upload_images: false,
skip_upload_screenshots: false,
# Only re-upload graphics whose content changed.
sync_image_upload: true,
validate_only: options[:dry_run].to_s == "true",
)
end
end
end
@@ -4,7 +4,7 @@ It is the third app in the Floret family, after Calendula (calendar) and Agendul
<b>Alarms that actually ring</b>
An alarm app is only worth having if you can trust it. Clockula schedules alarms the way Android intends — as real alarm clocks, exempt from battery saving, visible to the system lock screen and status bar. When something on your device would stop an alarm from ringing, Clockula tells you which setting it is and takes you there, instead of failing silently at 7am.
Clockula schedules alarms the way Android intends: as real alarm clocks, exempt from battery saving and visible on the lock screen and status bar. When something on your device would stop an alarm from ringing, Clockula tells you which setting it is and takes you there, instead of failing silently at 7am.
<b>Everything you expect</b>
@@ -17,6 +17,6 @@ A running timer or stopwatch stays reachable from every tab, so you never lose t
<b>Open, and yours</b>
Clockula speaks Android's standard alarm intents, so your assistant and your automation apps can set alarms and timers in it like any other clock app. Your alarms, timers and clocks export to a documented JSON file whenever you want them, because data you cannot take with you is not really yours.
Clockula speaks Android's standard alarm intents, so your assistant and your automation apps can set alarms and timers in it like any other clock app. Your alarms, timers and clocks export to a documented JSON file whenever you want them.
No account. No tracking. No ads. Free software under the MIT licence.
No account, no tracking, no ads. Free software under the MIT licence.
@@ -1 +1 @@
Alarms, timers, stopwatch and world clock. Material 3 Expressive, open source, no account.
Alarms, timers, stopwatch and world clock. Open source, no account, no tracking.
+10 -3
View File
@@ -52,9 +52,16 @@
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.
// New branches/PRs only in the Monday window (UTC), which brackets the weekly
// cron in .gitea/workflows/renovate.yml with slack for a late start — keep
// the two in step, or the sweep lands outside the window and finds nothing.
// The same workflow also runs on every push to main; those runs fall outside
// the window and, via the default updateNotScheduled: true, only maintain
// existing branches — chiefly rebasing PRs a merge just conflicted.
schedule: ["* 4-6 * * 1"],
// Rebase only on conflict, not every time main moves (automerge is off, so
// this matches "auto"; spelled out because the on-merge trigger relies on it).
rebaseWhen: "conflicted",
// Workflows are split by forge and neither directory is .github: CI lives in
// .forgejo/workflows (Codeberg) and the release/renovate jobs in
+13
View File
@@ -17,6 +17,8 @@
# 3. dependenciesInfo { includeInApk = false } — else AGP embeds a "Dependency
# metadata" block (id 0x504b4453) in the APK Signing Block, which F-Droid's
# binary scanner rejects as an extra signing block.
# 4. jniLibs { keepDebugSymbols += "**/*.so" } — else AGP strips prebuilt .so
# files only when an NDK is installed, so the bytes depend on the host.
set -euo pipefail
APP="app/build.gradle.kts"
@@ -62,6 +64,17 @@ else
fail=1
fi
# 4. Prebuilt native libs must not be stripped. AGP strips them only if an NDK is
# installed, so the output would depend on the build host.
if grep -Pzoq 'jniLibs\s*\{[^}]*keepDebugSymbols\s*\+=\s*"\*\*/\*\.so"' "$APP"; then
echo "OK: jniLibs keepDebugSymbols — native libs ship unstripped on every host."
else
echo "ERROR: '$APP' is missing 'jniLibs { keepDebugSymbols += \"**/*.so\" }'." >&2
echo " AGP strips .so files only when an NDK is present, so a from-source" >&2
echo " rebuild would not match the published APK." >&2
fail=1
fi
if [ "$fail" -ne 0 ]; then
echo >&2
echo "Reproducible-release invariant(s) violated — official F-Droid publishing would" >&2
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Validate fastlane/metadata/android against what BOTH stores accept.
The fastlane tree is the single source for the F-Droid listing (official repo
harvests it, the self-hosted repo gets it via fastlane_to_fdroid_localized.sh)
and for the Play listing (`bundle exec fastlane listing`). F-Droid accepts
nearly anything; Play rejects a lot at upload time. Checking against Play's
rules here is what keeps the graphics shippable to both from the same files.
scripts/check_store_listing.py validate what exists
scripts/check_store_listing.py --complete also require everything a Play
listing needs to go live
SDK- and dependency-free (PNG/JPEG headers are parsed by hand) so CI can run it
without setup.
"""
import re
import struct
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
META = ROOT / "fastlane/metadata/android"
RES = ROOT / "app/src/main/res"
# Play's limits; F-Droid truncates or recommends the same.
TEXT_LIMITS = {"title.txt": 30, "short_description.txt": 80, "full_description.txt": 4000}
CHANGELOG_LIMIT = 500
# The fallback locale of both stores: F-Droid always, Play as the app's default
# language. It carries the full text and every graphic.
REQUIRED_LOCALES = ("en-US",)
IMAGE_LOCALE = "en-US"
SCREENSHOT_DIRS = ("phoneScreenshots", "sevenInchScreenshots", "tenInchScreenshots",
"tvScreenshots", "wearScreenshots")
# Play policy on the title: no ranking, price or promotional terms.
TITLE_BANNED = re.compile(r"\b(best|top|#1|no\.? ?1|free|sale|discount|new|hot|download now)\b", re.I)
errors, warnings = [], []
def err(path, msg):
errors.append(f"{path.relative_to(ROOT)}: {msg}")
def warn(path, msg):
warnings.append(f"{path.relative_to(ROOT)}: {msg}")
def image_info(path):
"""(format, width, height, has_alpha) or None if unreadable."""
data = path.read_bytes()
if data[:8] == b"\x89PNG\r\n\x1a\n":
w, h, _depth, color = struct.unpack(">IIBB", data[16:26])
has_alpha = color in (4, 6) or b"tRNS" in data[:data.find(b"IDAT")]
return "png", w, h, has_alpha
if data[:2] == b"\xff\xd8":
i = 2
while i + 9 < len(data):
if data[i] != 0xFF:
i += 1
continue
marker = data[i + 1]
seg_len = struct.unpack(">H", data[i + 2:i + 4])[0]
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
h, w = struct.unpack(">HH", data[i + 5:i + 9])
return "jpeg", w, h, False
i += 2 + seg_len
return None
def check_image(path, *, size=None, alpha=None, max_mb, screenshot=False):
info = image_info(path)
if info is None:
err(path, "not a PNG or JPEG")
return
fmt, w, h, has_alpha = info
if size and (w, h) != size:
err(path, f"is {w}x{h}, Play requires exactly {size[0]}x{size[1]}")
if alpha is True and (fmt != "png" or not has_alpha):
err(path, "must be a 32-bit PNG (with alpha channel)")
if alpha is False and has_alpha:
err(path, "has an alpha channel; Play wants 24-bit PNG or JPEG")
if screenshot:
short, long_ = sorted((w, h))
if short < 320 or long_ > 3840:
err(path, f"is {w}x{h}; each side must be 320–3840 px")
if long_ > 2 * short:
err(path, f"is {w}x{h}; the long side may be at most twice the short side")
if short < 1080:
warn(path, f"is {w}x{h}; Play only features screenshots at 1080 px or more")
mb = path.stat().st_size / 1_000_000
if mb > max_mb:
err(path, f"is {mb:.1f} MB, over Play's {max_mb} MB limit")
def check_images(locale_dir):
images = locale_dir / "images"
if not images.is_dir():
return
if locale_dir.name != IMAGE_LOCALE:
warn(images, f"graphics belong in {IMAGE_LOCALE} only; both stores fall back to it")
for f in images.iterdir():
if f.is_file() and f.stem not in ("icon", "featureGraphic", "promoGraphic", "tvBanner"):
err(f, "unknown image; neither store will pick it up")
icon = images / "icon.png"
if icon.exists():
check_image(icon, size=(512, 512), alpha=True, max_mb=1)
for name, size in (("featureGraphic", (1024, 500)), ("promoGraphic", (180, 120)),
("tvBanner", (1280, 720))):
for f in images.glob(f"{name}.*"):
check_image(f, size=size, alpha=False, max_mb=15)
for d in SCREENSHOT_DIRS:
shots = sorted(p for p in (images / d).glob("*") if p.is_file())
if len(shots) > 8:
err(images / d, f"{len(shots)} screenshots; Play takes at most 8")
for s in shots:
check_image(s, alpha=False, max_mb=8, screenshot=True)
def version_code():
"""The committed version's code, or None for a beta (it ships no What's New)."""
def info(key):
return subprocess.run(["bash", str(ROOT / "scripts/version_info.sh"), key],
check=True, capture_output=True, text=True).stdout.strip()
return None if info("channel") == "beta" else int(info("version_code"))
def shipped_languages():
langs = set()
for d in RES.glob("values-*"):
if (d / "strings.xml").exists():
m = re.fullmatch(r"values-([a-z]{2,3})(?:-r([A-Z]{2}))?", d.name)
if m:
langs.add((m.group(1), m.group(2)))
return langs
def main():
complete = "--complete" in sys.argv[1:]
current = version_code()
locales = sorted(d for d in META.iterdir() if d.is_dir())
names = {d.name for d in locales}
for loc in REQUIRED_LOCALES:
if loc not in names:
err(META / loc, "missing; this locale is a store fallback and must exist")
for d in locales:
has_text = any((d / f).exists() for f in TEXT_LIMITS)
for fname, limit in TEXT_LIMITS.items():
f = d / fname
if not f.exists():
if has_text or d.name in REQUIRED_LOCALES:
err(f, "missing (a locale with any listing text needs all three files)")
continue
text = f.read_text(encoding="utf-8").strip()
if not text:
err(f, "empty")
elif len(text) > limit:
err(f, f"{len(text)} chars, limit {limit}")
if fname == "title.txt" and TITLE_BANNED.search(text):
warn(f, "promotional term in the title; Play policy forbids these")
for f in sorted((d / "changelogs").glob("*.txt")):
n = len(f.read_text(encoding="utf-8").strip())
if f.stem == str(current) and n > CHANGELOG_LIMIT:
err(f, f"{n} chars, limit {CHANGELOG_LIMIT}; Play would reject this release")
elif n > CHANGELOG_LIMIT:
warn(f, f"{n} chars, over {CHANGELOG_LIMIT} (already published)")
check_images(d)
for lang, region in sorted(shipped_languages(), key=str):
match = f"{lang}-{region}" if region else lang
if not any(n == match or n.startswith(f"{lang}-") for n in names):
warn(META, f"app ships values-{lang}{'-r' + region if region else ''} "
"but there is no store locale for it")
if complete:
images = META / IMAGE_LOCALE / "images"
if not (images / "icon.png").exists():
err(images / "icon.png", "required for Play")
if not list(images.glob("featureGraphic.*")):
err(images / "featureGraphic.png", "required for Play (1024x500, no alpha)")
if len(list((images / "phoneScreenshots").glob("*"))) < 2:
err(images / "phoneScreenshots", "Play requires at least 2 phone screenshots")
for loc in REQUIRED_LOCALES if current is not None else []:
if not (META / loc / "changelogs" / f"{current}.txt").exists():
err(META / loc / "changelogs" / f"{current}.txt", "no What's New for this version")
for w in warnings:
print(f"warning: {w}")
for e in errors:
print(f"ERROR: {e}", file=sys.stderr)
if errors:
return 1
print(f"Store listing OK ({len(locales)} locales, {len(warnings)} warnings).")
return 0
if __name__ == "__main__":
sys.exit(main())
+10 -7
View File
@@ -14,8 +14,8 @@
# short_description.txt -> summary.txt
# full_description.txt -> description.txt
# title.txt -> name.txt
# images/icon.png -> icon.png
# images/phoneScreenshots/* -> phoneScreenshots/*
# images/{icon,featureGraphic,promoGraphic,tvBanner}.* -> same name
# images/<type>Screenshots/* -> <type>Screenshots/*
# changelogs/<versionCode>.txt -> changelogs/<versionCode>.txt
# (changelogs are seeded into the fastlane tree by
# scripts/sync_changelog_to_fastlane.sh.)
@@ -32,11 +32,14 @@ for locdir in "$SRC"/*/; do
[ -f "$locdir/short_description.txt" ] && cp "$locdir/short_description.txt" "$dst/summary.txt"
[ -f "$locdir/full_description.txt" ] && cp "$locdir/full_description.txt" "$dst/description.txt"
[ -f "$locdir/title.txt" ] && cp "$locdir/title.txt" "$dst/name.txt"
[ -f "$locdir/images/icon.png" ] && cp "$locdir/images/icon.png" "$dst/icon.png"
if [ -d "$locdir/images/phoneScreenshots" ]; then
mkdir -p "$dst/phoneScreenshots"
cp "$locdir"images/phoneScreenshots/* "$dst/phoneScreenshots/"
fi
for img in "$locdir"images/{icon,featureGraphic,promoGraphic,tvBanner}.*; do
cp "$img" "$dst/"
done
for shots in "$locdir"images/*Screenshots/; do
t="$(basename "$shots")"
mkdir -p "$dst/$t"
cp "$shots"* "$dst/$t/"
done
# Per-version changelogs live in the same fastlane tree (see
# scripts/sync_changelog_to_fastlane.sh) and map straight across.
if [ -d "$locdir/changelogs" ]; then
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Publish TAG on Codeberg, which is canonical for tags and releases, with the
# signed APK and its SHA-256 checksum attached: the direct-download channel
# that Obtainium follows. Shared by .gitea/workflows/release.yaml (stable) and
# beta.yaml (pre-release). Run from the repo checkout: it pushes the tag.
#
# Codeberg push-mirrors branches and tags to Gitea, but releases aren't git
# objects and don't sync in either direction, so this pushes the tag straight
# to Codeberg and creates the release over the API. Skips cleanly when TOKEN is
# unset; fails loudly on anything else (see release.yaml for why).
#
# Env:
# TOKEN CODEBERG_RELEASE_TOKEN (write:repository); empty = skip
# API https://codeberg.org/api/v1/repos/<owner>/<repo>
# TAG, SHA vX.Y.Z[-beta.N] and the commit it marks
# PRERELEASE true | false
# NOTES_FILE release notes (scripts/release_notes.sh)
# APK the signed release APK
set -euo pipefail
if [ -z "${TOKEN:-}" ]; then
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
exit 0
fi
: "${API:?}" "${TAG:?}" "${SHA:?}" "${PRERELEASE:?}" "${NOTES_FILE:?}" "${APK:?}"
if [ ! -f "$APK" ]; then echo "No release APK at $APK." >&2; exit 1; fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
ASSET_APK="clockula_${TAG}.apk"
ASSET_SUM="${ASSET_APK}.sha256"
cp "$APK" "$WORK/$ASSET_APK"
( cd "$WORK" && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
# Push the tag to Codeberg ourselves. Under Codeberg-canonical the mirror runs
# Codeberg -> Gitea, so waiting for a tag to arrive from Gitea (what 0.3.2 did)
# would wait forever. The tag minted on Gitea is in fact *deleted* by the next
# mirror sync until Codeberg has it, so pushing it here is what makes it
# durable on both forges.
#
# Pushing the ref first and creating the release 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.
HOST=${API#https://}; HOST=${HOST%%/*}
REPO=${API#*/repos/}
git tag -f "$TAG" "$SHA"
git push -f "https://${REPO%%/*}:${TOKEN}@${HOST}/${REPO}.git" "refs/tags/$TAG"
python3 - "$TAG" "$PRERELEASE" "$NOTES_FILE" <<'PY' > "$WORK/payload.json"
import json, sys
tag, pre, notes = sys.argv[1:4]
print(json.dumps({
"tag_name": tag,
"name": tag,
"body": open(notes).read(),
"draft": False,
"prerelease": pre == "true",
}))
PY
# Create (or update) the release. Codeberg 500s on a POST/GET against a tag it
# has only just received (the release request outruns the indexing of the ref
# 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). A 5xx body still exits curl 0, so the
# loop, not `set -e`, controls the flow.
ID=""
for attempt in 1 2 3 4 5 6; do
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
if [ -n "$EXIST" ]; then
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @"$WORK/payload.json" "$API/releases/$EXIST"
ID="$EXIST"; break
fi
CODE=$(curl -s -o "$WORK/response.json" -w "%{http_code}" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @"$WORK/payload.json" "$API/releases")
echo "release POST attempt $attempt HTTP $CODE"
ID=$(jq -r '.id // empty' "$WORK/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.
for A in "$ASSET_APK" "$ASSET_SUM"; do
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$WORK/$A" \
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Create (or update) the release for TAG on the Gitea build instance, the
# changelog of record there, and attach the R8 mapping. Shared by
# .gitea/workflows/release.yaml and beta.yaml.
#
# Creating it with target_commitish makes Gitea create the tag at SHA, so the
# tag only ever marks a fully-shipped version. Upsert, so re-runs are safe.
#
# Env:
# TOKEN, API Gitea token and .../api/v1/repos/<owner>/<repo>
# TAG, SHA vX.Y.Z[-beta.N] and the commit it marks
# PRERELEASE true | false
# NOTES_FILE release notes (scripts/release_notes.sh)
# MAPPING optional path to R8's mapping.txt
set -euo pipefail
: "${TOKEN:?}" "${API:?}" "${TAG:?}" "${SHA:?}" "${PRERELEASE:?}" "${NOTES_FILE:?}"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
python3 - "$TAG" "$SHA" "$PRERELEASE" "$NOTES_FILE" <<'PY' > "$WORK/payload.json"
import json, sys
tag, sha, pre, notes = sys.argv[1:5]
print(json.dumps({
"tag_name": tag,
"target_commitish": sha,
"name": tag,
"body": open(notes).read(),
"draft": False,
"prerelease": pre == "true",
}))
PY
# PATCH if a release for the tag already exists, else POST a new one (which
# also creates the tag at target_commitish).
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
if [ -n "$ID" ]; then
CODE=$(curl -s -o "$WORK/response.json" -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @"$WORK/payload.json" "$API/releases/$ID")
OK=200
else
CODE=$(curl -s -o "$WORK/response.json" -w '%{http_code}' -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @"$WORK/payload.json" "$API/releases")
OK=201
fi
cat "$WORK/response.json"; echo
if [ "$CODE" != "$OK" ]; then
echo "Gitea release upsert failed with HTTP $CODE (expected $OK)" >&2
exit 1
fi
ID=$(jq -r '.id' "$WORK/response.json")
echo "Created/updated Gitea release $TAG at $SHA"
# The R8 mapping keeps user crash stacktraces deobfuscatable. It's not an APK,
# so it fits the no-binaries rule for this release. Best-effort: never fail a
# release over it.
attach_mapping() {
local asset="mapping-${TAG#v}.txt.gz" old
gzip -c "$MAPPING" > "$WORK/$asset"
# Replace any prior asset of the same name (re-run safe).
old=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$asset" '.[] | select(.name==$n) | .id')
if [ -n "$old" ]; then
curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$old" >/dev/null
fi
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$WORK/$asset" \
"$API/releases/$ID/assets?name=$asset" -o /dev/null -w "asset $asset HTTP %{http_code}\n"
}
if [ -z "${MAPPING:-}" ] || [ ! -f "$MAPPING" ]; then
echo "No mapping.txt (R8 off?) — skipping."
else
attach_mapping || echo "warning: could not attach the R8 mapping to $TAG" >&2
fi
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Print the forge release notes for VERSION: its `## [VERSION]` section of
# CHANGELOG.md. A beta (X.Y.Z-beta.N) normally has no section of its own, so it
# falls back to `## [Unreleased]`, i.e. what the release branch carries so far,
# under a line saying what a beta is.
#
# scripts/release_notes.sh 1.1.0 > release-notes.md
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
VERSION=${1:?usage: release_notes.sh VERSION}
section() {
awk -v ver="$1" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md | sed -e '/./,$!d'
}
NOTES=$(section "$VERSION")
if [[ "$VERSION" == *-beta.* ]]; then
[ -n "$NOTES" ] || NOTES=$(section Unreleased)
printf '%s\n\n' "**Beta of ${VERSION%%-*}, for testing.** It updates in place to later betas and to the stable release. Obtainium only offers betas with *Include prereleases* switched on; F-Droid and Play never get them."
fi
if [ -n "$NOTES" ]; then
printf '%s\n' "$NOTES"
else
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._"
fi
+45 -28
View File
@@ -1,41 +1,58 @@
#!/usr/bin/env bash
# Write the current version's CHANGELOG.md section into the fastlane changelog
# file that F-Droid harvests: fastlane/metadata/android/en-US/changelogs/<code>.txt
# (en-US is F-Droid's fallback locale, so it covers every language).
# Ensure the current version's "What's New" exists at
# fastlane/metadata/android/en-US/changelogs/<code>.txt, which both F-Droid and
# Google Play publish.
#
# Run this when cutting a release (after editing CHANGELOG.md and bumping
# versionName in app/build.gradle.kts) and COMMIT the result, so the OFFICIAL
# F-Droid repo — which reads the changelog from the tagged source tree — shows
# this version's "What's New". The self-hosted release pipeline also runs it so
# its changelog never depends on the file having been committed. Idempotent.
#
# Extraction matches the awk used for the Gitea release notes so all three
# (release notes, self-hosted changelog, official changelog) stay in sync.
# A COMMITTED file wins and is never rewritten: it is a hand-written summary
# under 500 characters (Play's cap), not a copy of the CHANGELOG.md section.
# Only when the file is missing is it generated from that section, so the
# self-hosted release pipeline always has something to publish. The other
# locales' changelogs are written by hand and never touched here.
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
[ -n "$VERSION" ] || { echo "No versionName in app/build.gradle.kts" >&2; exit 1; }
MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.}
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
VERSION=$(bash scripts/version_info.sh version)
VERSION_CODE=$(bash scripts/version_info.sh version_code)
# Betas only ship to Codeberg, whose notes come from CHANGELOG.md. A What's New
# file for one would sit in the tree F-Droid and Play read, so none is wanted.
if [ "$(bash scripts/version_info.sh channel)" = beta ]; then
echo "Beta $VERSION: no store What's New (betas only ship to Codeberg)."
exit 0
fi
# Play rejects a longer "What's New" and F-Droid truncates it in-client.
MAX_CHARS=${MAX_CHARS:-500}
CL_DIR="fastlane/metadata/android/en-US/changelogs"
mkdir -p "$CL_DIR"
OUT="$CL_DIR/${VERSION_CODE}.txt"
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > "$OUT"
# Trim leading blank lines (same as the pipeline did).
sed -i -e '/./,$!d' "$OUT"
if [ ! -s "$OUT" ]; then
echo "See CHANGELOG.md for $VERSION." > "$OUT"
if [ -s "$OUT" ]; then
ACTION="Kept"
else
ACTION="Generated"
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > "$OUT"
# Trim leading blank lines (same as the pipeline did).
sed -i -e '/./,$!d' "$OUT"
[ -s "$OUT" ] || echo "See CHANGELOG.md for $VERSION." > "$OUT"
fi
CHARS=$(wc -m < "$OUT" | tr -d ' ')
echo "Wrote $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)"
if [ "$CHARS" -gt 500 ]; then
echo " note: >500 chars — F-Droid may truncate this changelog in-client." >&2
# Counted like check_store_listing.py: surrounding whitespace doesn't count.
CHARS=$(python3 -c 'import sys; print(len(open(sys.argv[1], encoding="utf-8").read().strip()))' "$OUT")
echo "$ACTION $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)"
# Hard limit, enforced in CI (.forgejo/workflows/ci.yaml) and before a release.
if [ "$CHARS" -gt "$MAX_CHARS" ]; then
cat >&2 <<MSG
ERROR: $OUT is $CHARS characters, over the $MAX_CHARS-character limit.
F-Droid truncates the in-client changelog, so this one would be cut off
mid-sentence and Play would reject the upload. Replace it with a hand-written
summary under $MAX_CHARS characters and commit it.
MSG
exit 1
fi
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# The one place that turns the committed versionName into a versionCode and a
# release channel. The release and beta pipelines, the changelog sync and the
# store-listing check all read it from here, so the scheme can't drift between
# them.
#
# versionName channel versionCode
# X.Y.Z-beta.N beta X*1000000 + Y*10000 + Z*100 + N (N = 1..98)
# X.Y.Z stable X*1000000 + Y*10000 + Z*100 + 99
#
# A beta's code sits below its own stable release and above everything before
# it, so a beta install updates in place to the next beta and then to the
# stable version: 1.0.0-beta.1 -> 1000001, 1.0.0 -> 1000099.
#
# scripts/version_info.sh print version, version_code, base_version,
# channel and prerelease as key=value lines
# (ready for $GITHUB_OUTPUT)
# scripts/version_info.sh <key> print just that value
# scripts/version_info.sh --check also fail unless the committed versionCode
# matches; the official F-Droid repo builds
# the tag as committed, so it must
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
GRADLE="app/build.gradle.kts"
NAME=$(grep -oP 'versionName\s*=\s*"\K[^"]+' "$GRADLE" || true)
COMMITTED=$(grep -oP 'versionCode\s*=\s*\K[0-9]+' "$GRADLE" || true)
[ -n "$NAME" ] || { echo "No versionName in $GRADLE" >&2; exit 1; }
if [[ ! "$NAME" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-beta\.([0-9]+))?$ ]]; then
echo "versionName '$NAME' is neither X.Y.Z nor X.Y.Z-beta.N" >&2
exit 1
fi
MAJOR=$((10#${BASH_REMATCH[1]})); MINOR=$((10#${BASH_REMATCH[2]})); PATCH=$((10#${BASH_REMATCH[3]}))
BETA=${BASH_REMATCH[5]:+$((10#${BASH_REMATCH[5]}))}
BASE="$MAJOR.$MINOR.$PATCH"
if [ "$MINOR" -gt 99 ] || [ "$PATCH" -gt 99 ]; then
echo "versionName '$NAME': MINOR and PATCH each have room for 0-99" >&2
exit 1
fi
if [ -n "$BETA" ]; then
if [ "$BETA" -lt 1 ] || [ "$BETA" -gt 98 ]; then
echo "versionName '$NAME': beta number must be 1-98 (99 is the stable release)" >&2
exit 1
fi
CHANNEL=beta
CODE=$(( MAJOR * 1000000 + MINOR * 10000 + PATCH * 100 + BETA ))
else
CHANNEL=stable
CODE=$(( MAJOR * 1000000 + MINOR * 10000 + PATCH * 100 + 99 ))
fi
# Betas are always pre-releases; so was everything before 1.0.0.
if [ "$CHANNEL" = beta ] || [ "$MAJOR" = 0 ]; then PRERELEASE=true; else PRERELEASE=false; fi
case "${1:-}" in
"")
printf 'version=%s\nversion_code=%s\nbase_version=%s\nchannel=%s\nprerelease=%s\n' \
"$NAME" "$CODE" "$BASE" "$CHANNEL" "$PRERELEASE"
;;
--check)
if [ "$COMMITTED" != "$CODE" ]; then
echo "ERROR: $GRADLE has versionCode = ${COMMITTED:-<none>}, but versionName '$NAME' needs $CODE." >&2
echo "Set versionCode = $CODE (see docs/RELEASING.md for the scheme)." >&2
exit 1
fi
echo "OK: versionName $NAME -> versionCode $CODE ($CHANNEL)."
;;
version) echo "$NAME" ;;
version_code) echo "$CODE" ;;
base_version) echo "$BASE" ;;
channel) echo "$CHANNEL" ;;
prerelease) echo "$PRERELEASE" ;;
*) echo "Unknown key '$1'" >&2; exit 2 ;;
esac