Compare commits
3
Commits
main
...
v1.0.0-beta.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5052f3fa9 | ||
|
|
e870918a03 | ||
|
|
881f6b1286 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+14
-14
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
Executable
+96
@@ -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."
|
||||
Executable
+76
@@ -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
|
||||
Executable
+29
@@ -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
|
||||
@@ -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
|
||||
|
||||
Executable
+77
@@ -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
|
||||
Reference in New Issue
Block a user