Compare commits
159
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b630a230c8 | ||
|
|
3b14977368 | ||
|
|
d584a1a9a5 | ||
|
|
baffa9a188 | ||
|
|
d3af7d5b7c | ||
|
|
caa8c50db3 | ||
|
|
d957fdf23d | ||
|
|
c2a583fda7 | ||
|
|
df8be5e158 | ||
|
|
a0a1daa920 | ||
|
|
813bb52c03 | ||
|
|
fd7f5e8dcd | ||
|
|
48d09f7371 | ||
|
|
6f615be4ad | ||
|
|
a186e5afcd | ||
|
|
494e46a0c8 | ||
|
|
d81cd04b60 | ||
|
|
333a23d6be | ||
|
|
440d098c77 | ||
|
|
ea8e159b39 | ||
|
|
6aa046515c | ||
|
|
9c9b6171e0 | ||
|
|
2191166035 | ||
|
|
817fe77b17 | ||
|
|
74c0c278ef | ||
|
|
131a28c1be | ||
|
|
1a5552a221 | ||
|
|
8b182c999b | ||
|
|
b701859156 | ||
|
|
1a443c066f | ||
|
|
2dce78281a | ||
|
|
119197106b | ||
|
|
47165ce93f | ||
|
|
83a77c1bb0 | ||
|
|
69b436cffd | ||
|
|
bdcf780028 | ||
|
|
1559e68b2f | ||
|
|
2927585608 | ||
|
|
b8968f5e19 | ||
|
|
0218339161 | ||
|
|
dc23618c56 | ||
|
|
da16ab23cc | ||
|
|
99c052f94c | ||
|
|
26a2b78225 | ||
|
|
fe1c2a3d48 | ||
|
|
5c6ec88bb6 | ||
|
|
94f09e9e5a | ||
|
|
cb5cd5123b | ||
|
|
0213af1260 | ||
|
|
95bb2d14ec | ||
|
|
af70ee1b6a | ||
|
|
cba16c940c | ||
|
|
be2c0ada04 | ||
|
|
ba1172b822 | ||
|
|
4765b8e088 | ||
|
|
40c71a49f7 | ||
|
|
23f2ee94ea | ||
|
|
3b488ef6fc | ||
|
|
ddec2d8ed1 | ||
|
|
712b606779 | ||
|
|
9839520b7b | ||
|
|
beeca29f74 | ||
|
|
d744fa8d98 | ||
|
|
7adda73a79 | ||
|
|
5ad9bd02d6 | ||
|
|
c5de9becdf | ||
|
|
da9854abf8 | ||
|
|
fdb725e64c | ||
|
|
1e29226364 | ||
|
|
5f3b97b32a | ||
|
|
301ce2fec9 | ||
|
|
a88e696d52 | ||
|
|
697678b226 | ||
|
|
247dbcb751 | ||
|
|
93f6fdf98d | ||
|
|
b4af164826 | ||
|
|
b79b38bd2b | ||
|
|
62da327771 | ||
|
|
1dcd743e5b | ||
|
|
f570bdefb7 | ||
|
|
40b4c9e2e4 | ||
|
|
1253c0fc55 | ||
|
|
471cbdabd8 | ||
|
|
f46b7e1e37 | ||
|
|
af12b7bb34 | ||
|
|
1333001aa9 | ||
|
|
dedcbd5dfd | ||
|
|
b1a45db54a | ||
|
|
ef26d433e1 | ||
|
|
755150881b | ||
|
|
5b35ef65d4 | ||
|
|
f5388042c6 | ||
|
|
2feed2ee35 | ||
|
|
e43339c386 | ||
|
|
d70d0c78da | ||
|
|
706a384704 | ||
|
|
78da94d775 | ||
|
|
6239ca0477 | ||
|
|
d2e73901c1 | ||
|
|
17a44369b6 | ||
|
|
6d235e0974 | ||
|
|
1689794da6 | ||
|
|
9516dc1198 | ||
|
|
058fc27749 | ||
|
|
71875e95e4 | ||
|
|
0b7af0d57b | ||
|
|
ff49667d39 | ||
|
|
820317f9d9 | ||
|
|
7a86e5488e | ||
|
|
4cb4b6b6eb | ||
|
|
d2bf4349c5 | ||
|
|
cc2099c40a | ||
|
|
adc5a3bdd6 | ||
|
|
fb875ca71c | ||
|
|
fa5ce55503 | ||
|
|
65c86af313 | ||
|
|
84d31fe7f8 | ||
|
|
1c323d5b4c | ||
|
|
207475ef37 | ||
|
|
a4cec23e5c | ||
|
|
8910ab7fbf | ||
|
|
412e54db09 | ||
|
|
58c9bfebb1 | ||
|
|
fbc3a192bf | ||
|
|
785adf715b | ||
|
|
3f431a7f32 | ||
|
|
cac58a98db | ||
|
|
3003f75333 | ||
|
|
805da39074 | ||
|
|
85c12811b0 | ||
|
|
9af3d2b515 | ||
|
|
1823e885a1 | ||
|
|
3a9b7e16ea | ||
|
|
f9f2bdc9a8 | ||
|
|
7ca8d54b4a | ||
|
|
d7d958971f | ||
|
|
c99106321a | ||
|
|
9cd40d6813 | ||
|
|
99b357f4fb | ||
|
|
96fc9c19cf | ||
|
|
b883554dc4 | ||
|
|
4d29669222 | ||
|
|
d48c2e68fa | ||
|
|
ec391e8675 | ||
|
|
d5016183ad | ||
|
|
6637554c9d | ||
|
|
67010a632c | ||
|
|
405374de7e | ||
|
|
c83c12cf64 | ||
|
|
badd7aaa54 | ||
|
|
2f39b00398 | ||
|
|
27df918da0 | ||
|
|
11ea9be468 | ||
|
|
61eff32f5f | ||
|
|
276c88942e | ||
|
|
e49afb182c | ||
|
|
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: light
|
||||
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: gradle
|
||||
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.
|
||||
|
||||
+111
-233
@@ -4,240 +4,118 @@ 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.** 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, and when no sound
|
||||
can play it vibrates.
|
||||
- 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.
|
||||
- Setting an alarm, switching it on or changing its time or repeat days says
|
||||
how long until it rings, such as "Alarm set for 7 hours and 12 minutes from
|
||||
now", so an AM/PM or wrong-day slip shows at once.
|
||||
- Swipe an alarm away to delete it, or use its Delete action with a screen
|
||||
reader. Deleting an alarm, or a timer from its card, doesn't ask first:
|
||||
Undo brings it back with all its settings, and a snoozed alarm comes back
|
||||
snoozed.
|
||||
- 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.
|
||||
- An "Upcoming alarm" notification two hours before an alarm rings, with a
|
||||
Dismiss button, so you can turn off today's alarm when you're already awake.
|
||||
A repeating alarm skips just that once; a one-time alarm is switched off.
|
||||
The same Dismiss shows on the alarm in the list. The notification is silent,
|
||||
and turning it off in the system settings doesn't affect the alarm.
|
||||
- Schedule a one-off alarm for a date, such as a flight next Thursday, and
|
||||
pause a repeating alarm for a range of days, such as a holiday. The list
|
||||
shows "Scheduled for Wed, Oct 28" or "Paused Oct 26 – 28", and a pause ends
|
||||
on its own.
|
||||
- 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, the "Timer expired" sound from
|
||||
Android's own Clock, 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. It also opens when the alarm or a timer goes off
|
||||
while you're using Clockula, and with "Ring screen over other apps" turned
|
||||
on it opens over any app instead of a notification.
|
||||
- Choose what the volume buttons do while an alarm rings: change the volume
|
||||
(as before), snooze, dismiss or nothing. Dismiss still respects the dismiss
|
||||
challenge by snoozing instead, and a snooze press with no snoozes left
|
||||
changes the volume rather than ending the alarm. It works while the ring
|
||||
screen is showing.
|
||||
- **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. A finished
|
||||
timer turns the screen on and opens over the lock screen with Stop and
|
||||
+1 min. Timers keep counting through a reboot and aren't affected by changes
|
||||
to the clock.
|
||||
- Timers have their own vibration switch and an optional gradual volume, both
|
||||
apart from the alarms'. A kitchen timer can just beep while alarms still
|
||||
vibrate; a silent timer still vibrates. Timer vibration starts out matching
|
||||
the alarm vibration setting, so nothing changes until you change it.
|
||||
- **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. "Display time with seconds" in Settings > Clock adds seconds to
|
||||
every time on the tab and a second hand to the face.
|
||||
- An automatic local clock while travelling. When you've set a home time zone
|
||||
and your device is somewhere the time differs, the World clock tab adds a
|
||||
"Local time" row under the home face. It goes away again once you're back
|
||||
on home time, and stays hidden if one of your cities already shows that
|
||||
time. It's Google Clock's "automatic home clock" turned round: Clockula's
|
||||
face already shows home, so the clock that goes missing on a trip is the
|
||||
local one. Turn it off in Settings › Clock.
|
||||
- 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.
|
||||
- Choose how long a ringing alarm or a finished timer keeps going before it
|
||||
stops on its own: 1, 5, 10, 15, 20 or 25 minutes, or Never, separately for
|
||||
alarms and timers. Ten minutes stays the default, and Never still stops
|
||||
after an hour so a phone left at home can't ring its battery flat.
|
||||
- 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.
|
||||
- **Bedtime**, under Settings: Clockula can turn on a Do Not Disturb mode of
|
||||
its own at bedtime and off at your wake-up time, on the nights you choose,
|
||||
with an optional reminder before it starts. On Android 15 and later it is a
|
||||
real mode in Settings › Modes and can also turn the screen grayscale, dim the
|
||||
wallpaper, switch to the dark theme and hide the always-on display. It needs
|
||||
Do Not Disturb access, which the screen helps you grant, and it always lets
|
||||
alarms through. It is separate from Digital Wellbeing's own Bedtime mode,
|
||||
which apps can't turn on.
|
||||
- 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.
|
||||
- **Screen saver.** A full-screen digital or analog clock with the date and
|
||||
your next alarm, for a phone on a charger or a dock. Night mode turns it
|
||||
very dim for a dark bedroom, and the clock moves a little every minute so
|
||||
nothing burns into the screen. Pick it in Android's screen saver settings,
|
||||
or open it any time from the menu on each tab. Its settings are in Settings
|
||||
→ Clock and are included in backups.
|
||||
- Quick Settings tiles: "Next alarm" shows the day and time of your next alarm
|
||||
and opens the Alarms tab, and "Quick timer" starts a timer with one tap,
|
||||
shows when it ends, and resets it on a second tap. Long-press the quick
|
||||
timer tile to choose its length.
|
||||
|
||||
+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"
|
||||
|
||||
@@ -170,7 +170,10 @@ Weblate, and partial ones are fine: untranslated strings fall back to English.
|
||||
[Help translate Clockula](https://weblate.dev.jeanlucmakiola.de/engage/clockula/)
|
||||
|
||||
You don't need to code. Register on the Weblate server, pick a language (or
|
||||
request one) and translate in your browser.
|
||||
request one) and translate in your browser. The app links here too, from
|
||||
**Settings → About → Help translate**.
|
||||
|
||||
[](https://weblate.dev.jeanlucmakiola.de/engage/clockula/)
|
||||
|
||||
## Privacy
|
||||
|
||||
|
||||
+23
-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 = 1000005
|
||||
versionName = "1.0.0-beta.5"
|
||||
|
||||
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 {
|
||||
@@ -146,6 +154,16 @@ ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
// Compile against the library versions the APK actually ships. floret-kit's
|
||||
// `implementation` deps reach only the runtime classpath, so a newer material3
|
||||
// there once won at runtime while the app compiled against an older one, and
|
||||
// a removed ButtonGroup overload crashed every tab with NoSuchMethodError (#10).
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
variant.compileConfiguration.shouldResolveConsistentlyWith(variant.runtimeConfiguration)
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 4,
|
||||
"identityHash": "5704da9a79231cc225da71c4f8a672f8",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "alarms",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `label` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `repeat_days` INTEGER NOT NULL, `skip_next_occurrence` INTEGER NOT NULL, `ringtone_uri` TEXT, `vibrate` INTEGER, `snooze_minutes` INTEGER, `snooze_limit` INTEGER, `volume_ramp_seconds` INTEGER, `dismiss_challenge` TEXT, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `delete_after_use` INTEGER NOT NULL DEFAULT 0, `scheduled_date` INTEGER, `pause_start` INTEGER, `pause_end` INTEGER)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hour",
|
||||
"columnName": "hour",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "minute",
|
||||
"columnName": "minute",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "label",
|
||||
"columnName": "label",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "enabled",
|
||||
"columnName": "enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "repeatDays",
|
||||
"columnName": "repeat_days",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "skipNextOccurrence",
|
||||
"columnName": "skip_next_occurrence",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ringtoneUri",
|
||||
"columnName": "ringtone_uri",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "vibrate",
|
||||
"columnName": "vibrate",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "snoozeMinutes",
|
||||
"columnName": "snooze_minutes",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "snoozeLimit",
|
||||
"columnName": "snooze_limit",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "volumeRampSeconds",
|
||||
"columnName": "volume_ramp_seconds",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "dismissChallenge",
|
||||
"columnName": "dismiss_challenge",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updated_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleteAfterUse",
|
||||
"columnName": "delete_after_use",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "scheduledDate",
|
||||
"columnName": "scheduled_date",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "pauseStart",
|
||||
"columnName": "pause_start",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "pauseEnd",
|
||||
"columnName": "pause_end",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_alarms_enabled",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"enabled"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_alarms_enabled` ON `${TABLE_NAME}` (`enabled`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "alarm_states",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`alarm_id` INTEGER NOT NULL, `snoozed_until` INTEGER, `snooze_count` INTEGER NOT NULL, `ringing_since` INTEGER, `handled_occurrence` INTEGER, `skipped_occurrence` INTEGER, PRIMARY KEY(`alarm_id`), FOREIGN KEY(`alarm_id`) REFERENCES `alarms`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "alarmId",
|
||||
"columnName": "alarm_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snoozedUntil",
|
||||
"columnName": "snoozed_until",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "snoozeCount",
|
||||
"columnName": "snooze_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ringingSince",
|
||||
"columnName": "ringing_since",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "handledOccurrence",
|
||||
"columnName": "handled_occurrence",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "skippedOccurrence",
|
||||
"columnName": "skipped_occurrence",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"alarm_id"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "alarms",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"alarm_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "timers",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `duration_millis` INTEGER NOT NULL, `state` TEXT NOT NULL, `remaining_millis` INTEGER NOT NULL, `started_at_elapsed_realtime_millis` INTEGER, `ends_at_elapsed_realtime_millis` INTEGER, `ends_at_wall_clock_millis` INTEGER, `ringtone_uri` TEXT, `sort_order` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `delete_after_use` INTEGER NOT NULL DEFAULT 0)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "label",
|
||||
"columnName": "label",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "durationMillis",
|
||||
"columnName": "duration_millis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "state",
|
||||
"columnName": "state",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "remainingMillis",
|
||||
"columnName": "remaining_millis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "startedAtElapsedRealtimeMillis",
|
||||
"columnName": "started_at_elapsed_realtime_millis",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "endsAtElapsedRealtimeMillis",
|
||||
"columnName": "ends_at_elapsed_realtime_millis",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "endsAtWallClockMillis",
|
||||
"columnName": "ends_at_wall_clock_millis",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "ringtoneUri",
|
||||
"columnName": "ringtone_uri",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sort_order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updated_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleteAfterUse",
|
||||
"columnName": "delete_after_use",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_timers_sort_order",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sort_order"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_timers_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "world_clocks",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `zone_id` TEXT NOT NULL, `label` TEXT, `sort_order` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "zoneId",
|
||||
"columnName": "zone_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "label",
|
||||
"columnName": "label",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sort_order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_world_clocks_zone_id",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"zone_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_world_clocks_zone_id` ON `${TABLE_NAME}` (`zone_id`)"
|
||||
},
|
||||
{
|
||||
"name": "index_world_clocks_sort_order",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sort_order"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_world_clocks_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "stopwatch_laps",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `lap_index` INTEGER NOT NULL, `split_millis` INTEGER NOT NULL, `cumulative_millis` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lapIndex",
|
||||
"columnName": "lap_index",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "splitMillis",
|
||||
"columnName": "split_millis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cumulativeMillis",
|
||||
"columnName": "cumulative_millis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_stopwatch_laps_lap_index",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"lap_index"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_stopwatch_laps_lap_index` ON `${TABLE_NAME}` (`lap_index`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5704da9a79231cc225da71c4f8a672f8')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The only proof the v1 → v2 migration works. A migration that fails eats the
|
||||
* The only proof the migrations work. A migration that fails eats the
|
||||
* user's alarms, so it is validated against the exported schema rather than
|
||||
* eyeballed. Device only; the build gate compiles it.
|
||||
*/
|
||||
@@ -138,6 +138,47 @@ class MigrationTest {
|
||||
assertThat(timer!!.deleteAfterUse).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theThirdMigrationValidatesAgainstTheExportedVersionFourSchema() {
|
||||
helper.createDatabase(DB_NAME, 3).close()
|
||||
|
||||
val migrated = helper.runMigrationsAndValidate(DB_NAME, 4, true, Migrations.MIGRATION_3_4)
|
||||
|
||||
assertThat(migrated.version).isEqualTo(4)
|
||||
migrated.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alarmsWrittenAtVersionThreeSurviveUnscheduledAndUnpaused() {
|
||||
helper.createDatabase(DB_NAME, 3).apply {
|
||||
execSQL(insertAlarm)
|
||||
close()
|
||||
}
|
||||
|
||||
helper.runMigrationsAndValidate(DB_NAME, 4, true, Migrations.MIGRATION_3_4).close()
|
||||
|
||||
val alarm = runBlocking { migratedDatabase().alarmDao().findById(1L) }
|
||||
assertThat(alarm!!.label).isEqualTo("Gym")
|
||||
assertThat(alarm.repeatDays).isEqualTo(31)
|
||||
assertThat(alarm.scheduledDate).isNull()
|
||||
assertThat(alarm.pauseStart).isNull()
|
||||
assertThat(alarm.pauseEnd).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDatabaseFromVersionOneMigratesAllTheWayToTheCurrentSchema() {
|
||||
helper.createDatabase(DB_NAME, 1).apply {
|
||||
execSQL(insertAlarm)
|
||||
close()
|
||||
}
|
||||
|
||||
helper.runMigrationsAndValidate(DB_NAME, 4, true, *Migrations.ALL).close()
|
||||
|
||||
val alarm = runBlocking { migratedDatabase().alarmDao().findById(1L) }
|
||||
assertThat(alarm!!.deleteAfterUse).isFalse()
|
||||
assertThat(alarm.scheduledDate).isNull()
|
||||
}
|
||||
|
||||
private fun ringStateFor(alarmId: Long) = AlarmStateEntity(
|
||||
alarmId = alarmId,
|
||||
snoozedUntil = 1_700_000_100_000L,
|
||||
|
||||
@@ -3,12 +3,14 @@ package de.jeanlucmakiola.clockula.ui
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performCustomAccessibilityActionWithLabel
|
||||
import androidx.compose.ui.test.performTextReplacement
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.espresso.Espresso
|
||||
@@ -205,12 +207,38 @@ class AlarmsInstrumentedTest {
|
||||
composeTestRule.onNodeWithText("Work").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// No confirmation: the list underneath offers the Undo instead.
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.editor_delete)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_delete)).performClick()
|
||||
// The delete runs on the application scope, which Compose's idling
|
||||
// does not track, so the test waits for storage rather than for idle.
|
||||
composeTestRule.waitUntil(STORAGE_TIMEOUT_MS) { stored(id) == null }
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Work").assertDoesNotExist()
|
||||
assertThat(stored(id)).isNull()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_undo)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
@Test
|
||||
fun theRowsDeleteActionRemovesItAndUndoBringsItBack() {
|
||||
val id = givenAlarm()
|
||||
launch()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Work")
|
||||
.performCustomAccessibilityActionWithLabel(string(R.string.action_delete))
|
||||
composeTestRule.waitUntil(STORAGE_TIMEOUT_MS) { stored(id) == null }
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.action_undo)).performClick()
|
||||
composeTestRule.waitUntil(STORAGE_TIMEOUT_MS) { stored(id) != null }
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Work").assertIsDisplayed()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Delete and restore run on the application scope, outside Compose's idling. */
|
||||
const val STORAGE_TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@
|
||||
heads-up notification that still sounds — never to silence. -->
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
|
||||
<!-- The opt-in "Ring screen over other apps": while it is granted, a ring
|
||||
screen opens over the app in use instead of a heads-up. Not granted
|
||||
at install; turning the setting on sends the user to grant it. -->
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
|
||||
<!-- The ring notification. Denied, the alarm still sounds: the audio belongs
|
||||
to the foreground service, not to the notification. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
@@ -50,6 +55,12 @@
|
||||
<!-- The alarm vibrates, and vibrates on its own when no audio source opens. -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- The opt-in bedtime mode (issue #1): lists Clockula under Settings ›
|
||||
Special app access › Do Not Disturb, where the user grants it by hand.
|
||||
Without that grant nothing changes; with it, Clockula adds one Do Not
|
||||
Disturb rule of its own, which always lets alarms through. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
|
||||
|
||||
<!-- "Choose from a music app…": package visibility for the apps that share
|
||||
a library through Android's standard MediaBrowserService, and for
|
||||
nothing else. A visibility declaration, not a permission — and no
|
||||
@@ -183,6 +194,18 @@
|
||||
android:taskAffinity=""
|
||||
android:turnScreenOn="true" />
|
||||
|
||||
<!-- The timer's ring screen: the alarm's window settings, opened by the
|
||||
expired-timer notification's full-screen intent (issue #15). -->
|
||||
<activity
|
||||
android:name=".ui.ring.TimerRingActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTask"
|
||||
android:showOnLockScreen="true"
|
||||
android:showWhenLocked="true"
|
||||
android:taskAffinity=""
|
||||
android:turnScreenOn="true" />
|
||||
|
||||
<!-- The ring itself. systemExempted per D17: not mediaPlayback (an alarm
|
||||
is not the user's media session) and emphatically not shortService,
|
||||
which caps at ~3 minutes against a 10-minute ring window. -->
|
||||
@@ -191,7 +214,7 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="systemExempted" />
|
||||
|
||||
<!-- The two AlarmManager slots arriving. Unexported: a PendingIntent the
|
||||
<!-- The AlarmManager slots arriving. Unexported: a PendingIntent the
|
||||
app created is delivered regardless, and nothing else may fake a fire. -->
|
||||
<receiver
|
||||
android:name=".alarm.receiver.AlarmFireReceiver"
|
||||
@@ -257,9 +280,61 @@
|
||||
<action android:name="android.intent.action.TIME_SET" />
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- The bedtime slot arriving (issue #1). Unexported, like the alarm's
|
||||
and the timer's: only the PendingIntent the app made reaches it. -->
|
||||
<receiver
|
||||
android:name=".bedtime.receiver.BedtimeReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- The screen saver (issue #25). The system binds it on a charger or a
|
||||
dock once the user picks it in Settings → Display → Screen saver.
|
||||
Exported, since the dream manager is another process, and guarded
|
||||
by BIND_DREAM_SERVICE, a signature permission only the system
|
||||
holds, so nothing else can bind it. -->
|
||||
<service
|
||||
android:name=".ui.screensaver.ClockulaDreamService"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_DREAM_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.dreams.DreamService" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.service.dream"
|
||||
android:resource="@xml/screen_saver_info" />
|
||||
</service>
|
||||
|
||||
<!-- The screen saver's settings, opened by the gear beside it in the
|
||||
system's screen saver list. Exported because only an exported
|
||||
activity can be started by the Settings app; it reads no extras
|
||||
and changes nothing a user could not change in-app. A task of its
|
||||
own and out of recents, so opening it from Settings never lands
|
||||
in, or clears, the app's own task. -->
|
||||
<activity
|
||||
android:name=".ui.screensaver.ScreenSaverSettingsActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:label="@string/screen_saver_title"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/Theme.Clockula" />
|
||||
|
||||
<!-- The same screen saver on demand, from the tabs' overflow menu: an
|
||||
app cannot start the system's dream itself. Not exported; it
|
||||
finishes itself once no longer shown. Rotating it into a stand,
|
||||
docking it, or the dark-theme schedule firing re-lays it out
|
||||
rather than recreating it, which would flash black and reset its
|
||||
brightness. -->
|
||||
<activity
|
||||
android:name=".ui.screensaver.ScreenSaverActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|keyboard|navigation|uiMode|density|fontScale|locale|layoutDirection"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Clockula.ScreenSaver" />
|
||||
|
||||
<!-- Persists the per-app language on API < 33, where the platform
|
||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||
<service
|
||||
@@ -354,7 +429,7 @@
|
||||
updatePeriodMillis is 0 in every provider-info XML — WidgetSync
|
||||
is the one refresh trigger for the data widgets, and the analog
|
||||
widget's date is redrawn at midnight and on a clock or zone
|
||||
change (widget/AnalogDateRefresh.kt). -->
|
||||
change (widget/MidnightRefresh.kt). -->
|
||||
<receiver
|
||||
android:name=".widget.NextAlarmWidgetReceiver"
|
||||
android:label="@string/widget_next_alarm_label"
|
||||
@@ -426,6 +501,58 @@
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_quick_timer_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- #26: the two Quick Settings tiles. A TileService must be exported
|
||||
for System UI to bind it, and BIND_QUICK_SETTINGS_TILE — a
|
||||
signature permission only System UI holds — keeps anyone else
|
||||
from binding it. Both are active tiles: they are redrawn only
|
||||
when the app asks (`widget/WidgetSync.kt`), never on every panel
|
||||
open. -->
|
||||
<service
|
||||
android:name=".tile.NextAlarmTileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/widget_ic_alarm"
|
||||
android:label="@string/tile_next_alarm_label"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.service.quicksettings.ACTIVE_TILE"
|
||||
android:value="true" />
|
||||
</service>
|
||||
<service
|
||||
android:name=".tile.QuickTimerTileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/tile_ic_timer"
|
||||
android:label="@string/tile_quick_timer_label"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.service.quicksettings.ACTIVE_TILE"
|
||||
android:value="true" />
|
||||
<meta-data
|
||||
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<!-- What long-pressing either tile opens: the quick-timer tile's
|
||||
duration, or the Alarms tab for the next-alarm one. Exported
|
||||
because System UI starts it; it answers that one action and
|
||||
closes on any other. A task of its own, so finishing it returns
|
||||
to wherever the user was, not into an open Clockula. -->
|
||||
<activity
|
||||
android:name=".ui.tile.TilePreferencesActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/Theme.Clockula">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -4,6 +4,11 @@ import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
|
||||
import de.jeanlucmakiola.clockula.alarm.ring.UpcomingNotifications
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
|
||||
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeNotifier
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
|
||||
import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications
|
||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||
@@ -12,6 +17,7 @@ import de.jeanlucmakiola.clockula.widget.WidgetSync
|
||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import de.jeanlucmakiola.floret.di.ApplicationScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -38,9 +44,15 @@ class ClockulaApp : Application() {
|
||||
@Inject
|
||||
lateinit var stopwatchEngine: StopwatchEngine
|
||||
|
||||
@Inject
|
||||
lateinit var bedtimeEngine: BedtimeEngine
|
||||
|
||||
@Inject
|
||||
lateinit var widgetSync: WidgetSync
|
||||
|
||||
@Inject
|
||||
lateinit var settings: SettingsPrefs
|
||||
|
||||
@Inject
|
||||
@ApplicationScope
|
||||
lateinit var scope: CoroutineScope
|
||||
@@ -58,32 +70,61 @@ class ClockulaApp : Application() {
|
||||
),
|
||||
)
|
||||
|
||||
ForegroundRingScreen.track(this)
|
||||
RingNotifications.createChannels(this)
|
||||
UpcomingNotifications.createChannel(this)
|
||||
TimerNotifications.createChannel(this)
|
||||
StopwatchNotifications.createChannel(this)
|
||||
AndroidBedtimeNotifier.createChannel(this)
|
||||
|
||||
// The same three steps as a BOOT_COMPLETED, and taken through the engine
|
||||
// rather than one by one so the whole pass runs under the engine's lock:
|
||||
// a cold start caused by the fire broadcast must not interleave its
|
||||
// read-modify-write over `alarm_states` with the fire's own.
|
||||
scope.launch {
|
||||
engine.onBootCompleted()
|
||||
// After the alarm engine's pass, and idempotent within a boot: the
|
||||
// boot-id gate makes the second repair a no-op, so neither engine
|
||||
// depends on the other's ordering (M6 D21). Process death costs the
|
||||
// timers nothing — the anchors and the AlarmManager slot survive it —
|
||||
// but the service may not have, so this brings it back.
|
||||
timerEngine.onBootCompleted()
|
||||
// And the third, after the other two. Process death costs the
|
||||
// stopwatch nothing — the run is in DataStore and the laps in Room —
|
||||
// but the service may not have survived it, so this brings it back;
|
||||
// and if this is a new boot the gate above has already repaired the
|
||||
// run, leaving it paused at what it banked (M7 D17).
|
||||
stopwatchEngine.onBootCompleted()
|
||||
try {
|
||||
launchPasses()
|
||||
} finally {
|
||||
// Last and guarded, like every bedtime call on a shared path,
|
||||
// and in a finally so a failure above cannot cost it its pass:
|
||||
// a force stop clears its AlarmManager slot too, and may have
|
||||
// slept through a bedtime or wake-up, so both are caught up here.
|
||||
runCatching { bedtimeEngine.resync() }
|
||||
}
|
||||
}
|
||||
|
||||
// Never completes on its own — it is the widgets' one refresh trigger
|
||||
// for as long as the process lives (M10, docs/PLAN.md §9).
|
||||
// Never completes on its own — it is the widgets' and the Quick
|
||||
// Settings tiles' one refresh trigger for as long as the process
|
||||
// lives (M10, docs/PLAN.md §9; #26).
|
||||
scope.launch { widgetSync.run() }
|
||||
|
||||
scope.launch { settleTimerVibrate() }
|
||||
}
|
||||
|
||||
/** Off the engines' pass: a preference copy has no business delaying an alarm. */
|
||||
private suspend fun settleTimerVibrate() {
|
||||
try {
|
||||
settings.settleTimerVibrate()
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (_: Exception) {
|
||||
// Until it lands timers vibrate, the default; the next launch tries again.
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun launchPasses() {
|
||||
engine.onBootCompleted()
|
||||
// After the alarm engine's pass, and idempotent within a boot: the
|
||||
// boot-id gate makes the second repair a no-op, so neither engine
|
||||
// depends on the other's ordering (M6 D21). Process death costs the
|
||||
// timers nothing — the anchors and the AlarmManager slot survive it —
|
||||
// but the service may not have, so this brings it back.
|
||||
timerEngine.onBootCompleted()
|
||||
// And the third, after the other two. Process death costs the
|
||||
// stopwatch nothing — the run is in DataStore and the laps in Room —
|
||||
// but the service may not have survived it, so this brings it back;
|
||||
// and if this is a new boot the gate above has already repaired the
|
||||
// run, leaving it paused at what it banked (M7 D17).
|
||||
stopwatchEngine.onBootCompleted()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.interop.InteropIntents
|
||||
import de.jeanlucmakiola.clockula.ui.crash.CrashReportActivity
|
||||
@@ -165,7 +166,12 @@ class MainActivity : ComponentActivity() {
|
||||
.getOrNull()
|
||||
?.toList()
|
||||
.orEmpty()
|
||||
return ShellNavigation.requestFor(intent.action, alarmId, alarmIds)
|
||||
// Settings opens the bedtime rule's configuration activity (this one)
|
||||
// with the rule id and no action; that is the bedtime screen (issue #1).
|
||||
val forZenRule = intent.action == null &&
|
||||
runCatching { BedtimeIntents.ZEN_RULE_EXTRAS.any(intent::hasExtra) }.getOrDefault(false)
|
||||
val action = if (forZenRule) BedtimeIntents.ACTION_SHOW_BEDTIME else intent.action
|
||||
return ShellNavigation.requestFor(action, alarmId, alarmIds)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
@@ -5,28 +5,36 @@ import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepository
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
import de.jeanlucmakiola.clockula.domain.AlarmSettings
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmDeletion
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmResolution
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmResolver
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmSchedule
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.DeletedAlarm
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.DismissOutcome
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.FireOutcome
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.FireSource
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.ScheduledFire
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.SnoozeOutcome
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingAlarm
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingNotices
|
||||
import de.jeanlucmakiola.clockula.domain.interop.InteropLimits
|
||||
import de.jeanlucmakiola.clockula.domain.resolveSettings
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
|
||||
import de.jeanlucmakiola.clockula.system.RebootRepair
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
@@ -78,16 +86,55 @@ class AlarmEngine @Inject constructor(
|
||||
|
||||
suspend fun onAutoSilence(alarmId: Long): Unit = lock.withLock {
|
||||
val state = states.state(alarmId)
|
||||
if (state.ringingSince == null) return@withLock
|
||||
val since = state.ringingSince ?: return@withLock
|
||||
|
||||
// A ring still inside the window in force keeps ringing: a backstop
|
||||
// broadcast already queued when "Silence after" was raised must not end
|
||||
// the time just granted. A negative age is a clock moved back whose
|
||||
// TIME_SET has not been handled yet; the backstop has then had its
|
||||
// window in real time, and closes the ring as in v1.
|
||||
val age = wallClock.now() - since
|
||||
if (age >= Duration.ZERO && rearmIfInsideWindowLocked(state)) return@withLock
|
||||
|
||||
// Missed, not snoozed: the ring stops, the cycle closes, the next
|
||||
// occurrence is scheduled. There is no missed-alarm notification in v1.
|
||||
closeCycleLocked(alarmId, state)
|
||||
}
|
||||
|
||||
/** [minutesOverride] is a one-off, already clamped; it is never persisted anywhere (M9 D15). */
|
||||
suspend fun snooze(alarmId: Long, minutesOverride: Int? = null): SnoozeOutcome =
|
||||
lock.withLock { snoozeLocked(alarmId, minutesOverride) }
|
||||
/**
|
||||
* Writes the alarms' "Silence after" — the engine is its only writer outside
|
||||
* a backup restore — and measures a ring in progress against the new
|
||||
* window at once: one already past it closes as missed, exactly as the
|
||||
* backstop would close it, and one still inside it has its backstop moved
|
||||
* to the new end. Measured from when the ring started, so changing the
|
||||
* setting back and forth cannot buy a ring a fresh window.
|
||||
*/
|
||||
suspend fun setAlarmSilenceAfter(minutes: Int): Unit = withContext(NonCancellable) {
|
||||
// Not cancellable: a write that lands without the re-measure would leave
|
||||
// a ring in progress on the old window.
|
||||
lock.withLock {
|
||||
settings.setAlarmSilenceAfterMinutes(minutes)
|
||||
remeasureRingLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun remeasureRingLocked() {
|
||||
val state = states.ringing() ?: return
|
||||
if (!rearmIfInsideWindowLocked(state)) closeCycleLocked(state.alarmId, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* [minutesOverride] is a one-off, already clamped; it is never persisted anywhere (M9 D15).
|
||||
*
|
||||
* A snooze past the limit dismisses, unless [dismissIfRefused] is false:
|
||||
* then nothing changes and the alarm keeps ringing. A volume key passes
|
||||
* false (#18), since the press is blind and must not end the alarm.
|
||||
*/
|
||||
suspend fun snooze(
|
||||
alarmId: Long,
|
||||
minutesOverride: Int? = null,
|
||||
dismissIfRefused: Boolean = true,
|
||||
): SnoozeOutcome = lock.withLock { snoozeLocked(alarmId, minutesOverride, dismissIfRefused) }
|
||||
|
||||
/**
|
||||
* The `AlarmClock` contract's dismissal, and the chooser dialog's (M9 D14).
|
||||
@@ -112,22 +159,116 @@ class AlarmEngine @Inject constructor(
|
||||
// is written and the caller is told so.
|
||||
if (!alarm.enabled) return@withLock DismissOutcome.NotFound
|
||||
|
||||
// "Dismissing a repeating alarm only prevents the upcoming instance from
|
||||
// ringing"; dismissing a single occurrence "effectively disables" it.
|
||||
if (alarm.repeatDays.isRepeating) {
|
||||
alarms.setSkipNextOccurrence(alarmId, true)
|
||||
rescheduleLocked()
|
||||
return@withLock DismissOutcome.SkippedNext(alarmId)
|
||||
}
|
||||
dismissComingLocked(alarm)
|
||||
}
|
||||
|
||||
disableOrDeleteLocked(alarm)
|
||||
/**
|
||||
* The upcoming-alarm notification's Dismiss, and the row's chip (#16):
|
||||
* [dismissUpcoming], but only for the occurrence at [fireAt] and only while
|
||||
* it is still to come. A tap that lands after the alarm started ringing, or
|
||||
* after it moved, is [DismissOutcome.Stale] and dismisses nothing — the ring
|
||||
* screen and its dismiss challenge own a ringing alarm. No challenge is
|
||||
* asked before the ring, as in AOSP's Clock: skipping tomorrow's alarm is
|
||||
* already one switch away in the editor, unchallenged.
|
||||
*/
|
||||
suspend fun dismissUpcomingOccurrence(alarmId: Long, fireAt: Instant): DismissOutcome = lock.withLock {
|
||||
val alarm = alarms.find(alarmId)?.takeIf { it.enabled } ?: run {
|
||||
// Its notice, if one is still up, is put right on the way out.
|
||||
rescheduleLocked()
|
||||
return@withLock DismissOutcome.NotFound
|
||||
}
|
||||
val now = wallClock.now()
|
||||
// Read-only: the resolver's state is discarded, and the reschedule below
|
||||
// persists whatever it should. `fireAt > now` because the resolver keeps
|
||||
// a just-passed occurrence through the fire grace: one already due is
|
||||
// the fire's, not the notice's.
|
||||
val resolution = AlarmResolver.resolve(
|
||||
alarm,
|
||||
states.state(alarmId),
|
||||
now,
|
||||
zones.current(),
|
||||
settings.currentDefaults().alarmSilenceAfter,
|
||||
)
|
||||
if (resolution.nextFire != fireAt || !UpcomingNotices.isInWindow(fireAt, resolution.source, now)) {
|
||||
// Whatever posted the stale notice is put right on the way out.
|
||||
rescheduleLocked()
|
||||
return@withLock DismissOutcome.Stale
|
||||
}
|
||||
dismissComingLocked(alarm)
|
||||
}
|
||||
|
||||
/**
|
||||
* The alarm's time, days or pause were just edited: a skip armed on the
|
||||
* old shape of its sequence is re-armed on the new one, so "skip next"
|
||||
* still means the next ring — not one the old pause jumped to, an
|
||||
* occurrence of the old time, nor one a new pause already silences. A
|
||||
* watermark already due is left for the resolver to consume as usual.
|
||||
*/
|
||||
suspend fun onScheduleEdited(alarmId: Long): ScheduledFire? = lock.withLock {
|
||||
val state = states.state(alarmId)
|
||||
val armed = state.skippedOccurrence
|
||||
if (armed != null && armed > wallClock.now()) states.save(state.copy(skippedOccurrence = null))
|
||||
rescheduleLocked()
|
||||
DismissOutcome.Disabled(alarmId)
|
||||
}
|
||||
|
||||
suspend fun dismiss(alarmId: Long): Unit =
|
||||
lock.withLock { closeCycleLocked(alarmId, states.state(alarmId)) }
|
||||
|
||||
/**
|
||||
* The user's delete, from the list's swipe or the editor. Returns what an
|
||||
* Undo would put back ([AlarmDeletion.snapshot]), or null when there is
|
||||
* nothing to put back.
|
||||
*
|
||||
* On the engine, under its lock, for the same reason as [dismissUpcoming]:
|
||||
* a ringing alarm's service and auto-silence backstop are global slots
|
||||
* only the engine may touch, and a snoozed alarm's notification would
|
||||
* otherwise outlive the row it belongs to. The ring state goes with the row
|
||||
* (the foreign key cascades). Non-cancellable, like [restore]: a caller
|
||||
* cancelled half-way must not leave the slot registered for a deleted row.
|
||||
*
|
||||
* [unlessRinging] is the list's delete: a swipe released just as the alarm
|
||||
* fired, before the list knew, must not silence it round its dismiss
|
||||
* challenge, so it writes nothing and returns null. Checked here, under the
|
||||
* lock, because only here is the answer not already stale.
|
||||
*/
|
||||
suspend fun delete(alarmId: Long, unlessRinging: Boolean = false): DeletedAlarm? = lock.withLock {
|
||||
withContext(NonCancellable) { deleteLocked(alarmId, unlessRinging) }
|
||||
}
|
||||
|
||||
private suspend fun deleteLocked(alarmId: Long, unlessRinging: Boolean): DeletedAlarm? {
|
||||
val alarm = alarms.find(alarmId) ?: return null
|
||||
val state = states.state(alarmId)
|
||||
if (unlessRinging && state.isRinging) return null
|
||||
silenceLocked(alarmId, state)
|
||||
alarms.delete(alarmId)
|
||||
rescheduleLocked()
|
||||
return AlarmDeletion.snapshot(alarm, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Undo: the row back under its own id, its ring state with it, and the
|
||||
* slot re-resolved, which is what re-registers it with AlarmManager. False
|
||||
* when the id is taken, which writes nothing.
|
||||
*
|
||||
* A snooze comes back with its notification, but only if the resolver
|
||||
* still holds it pending once the slot is re-resolved: one that lapsed
|
||||
* while the alarm was gone is closed there, like any other.
|
||||
*
|
||||
* Non-cancellable: a row restored but never rescheduled would be an alarm
|
||||
* on the list that AlarmManager does not know about.
|
||||
*/
|
||||
suspend fun restore(deleted: DeletedAlarm): Boolean = lock.withLock {
|
||||
withContext(NonCancellable) { restoreLocked(deleted) }
|
||||
}
|
||||
|
||||
private suspend fun restoreLocked(deleted: DeletedAlarm): Boolean {
|
||||
if (!alarms.restore(deleted.alarm)) return false
|
||||
if (deleted.state != AlarmRingState.initial(deleted.alarm.id)) states.save(deleted.state)
|
||||
rescheduleLocked()
|
||||
states.state(deleted.alarm.id).snoozedUntil?.let { until -> notifier.showSnoozed(deleted.alarm, until) }
|
||||
return true
|
||||
}
|
||||
|
||||
/** Boot repair (timers + stopwatch), then resume any interrupted ring, then reschedule. */
|
||||
suspend fun onBootCompleted(): ScheduledFire? = lock.withLock {
|
||||
rebootRepair.repairIfRebooted()
|
||||
@@ -135,28 +276,56 @@ class AlarmEngine @Inject constructor(
|
||||
rescheduleLocked()
|
||||
}
|
||||
|
||||
suspend fun onSystemTimeOrZoneChanged(): ScheduledFire? = reschedule()
|
||||
/**
|
||||
* The backstop is a wall-clock registration, so a clock change moves it. A
|
||||
* ring in progress is re-measured first: a clock moved forward past its
|
||||
* window closes it, one moved back re-arms it a window from now at most.
|
||||
*/
|
||||
suspend fun onSystemTimeOrZoneChanged(): ScheduledFire? = lock.withLock {
|
||||
remeasureRingLocked()
|
||||
rescheduleLocked()
|
||||
}
|
||||
|
||||
suspend fun onPackageReplaced(): ScheduledFire? = reschedule()
|
||||
|
||||
/** An upcoming-alarm window opened (#16); the reschedule posts its notice. */
|
||||
suspend fun onUpcomingWindow(): ScheduledFire? = reschedule()
|
||||
|
||||
/** Restarts a ring the process death or reboot interrupted. Returns the alarm id, or null. */
|
||||
suspend fun resumeRingingIfAny(): Long? = lock.withLock { resumeRingingIfAnyLocked() }
|
||||
|
||||
/**
|
||||
* Whether storage holds a ringing alarm, re-read on every write. The ring
|
||||
* screen watches this as well as [RingCoordinator.ringing]: a backup restore
|
||||
* stops the coordinator first and deletes the ringing row afterwards.
|
||||
*/
|
||||
fun ringStored(): Flow<Boolean> =
|
||||
states.states().map { all -> all.any { it.ringingSince != null } }.distinctUntilChanged()
|
||||
|
||||
/** What the ringing service needs. Null when nothing is ringing. */
|
||||
suspend fun ringSession(): RingSession? = lock.withLock {
|
||||
val state = states.ringing() ?: return@withLock null
|
||||
val since = state.ringingSince ?: return@withLock null
|
||||
val alarm = alarms.find(state.alarmId) ?: return@withLock null
|
||||
|
||||
val resolved: AlarmSettings = alarm.resolveSettings(settings.currentDefaults())
|
||||
val defaults = settings.currentDefaults()
|
||||
val resolved: AlarmSettings = alarm.resolveSettings(defaults)
|
||||
RingSession(
|
||||
alarm = alarm,
|
||||
settings = resolved,
|
||||
startedAt = since,
|
||||
canSnooze = state.snoozeCount < resolved.snoozeLimit,
|
||||
volumeButtons = defaults.volumeButtons,
|
||||
)
|
||||
}
|
||||
|
||||
/** Silence the current service without changing its persisted ring state. */
|
||||
suspend fun suspendRingForImport(): Unit = lock.withLock {
|
||||
val alarmId = states.ringing()?.alarmId ?: return@withLock
|
||||
ring.stopRinging(alarmId)
|
||||
scheduler.cancelAutoSilence()
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only. Never writes. Sorted by nextFire ascending, nulls last, then by id.
|
||||
*
|
||||
@@ -165,7 +334,12 @@ class AlarmEngine @Inject constructor(
|
||||
* emission; a live readout passes a `Ticker`'s flow (M5 D8).
|
||||
*/
|
||||
fun upcoming(refresh: Flow<Unit> = flowOf(Unit)): Flow<List<UpcomingAlarm>> =
|
||||
combine(alarms.alarms(), states.states(), refresh) { alarms, states, _ ->
|
||||
combine(
|
||||
alarms.alarms(),
|
||||
states.states(),
|
||||
settings.defaults.map { it.alarmSilenceAfter }.distinctUntilChanged(),
|
||||
refresh,
|
||||
) { alarms, states, silenceAfter, _ ->
|
||||
val now = wallClock.now()
|
||||
val zone = zones.current()
|
||||
val byId = states.associateBy { it.alarmId }
|
||||
@@ -178,6 +352,7 @@ class AlarmEngine @Inject constructor(
|
||||
byId[alarm.id] ?: AlarmRingState.initial(alarm.id),
|
||||
now,
|
||||
zone,
|
||||
silenceAfter,
|
||||
)
|
||||
UpcomingAlarm(alarm, resolution.nextFire, resolution.source)
|
||||
}
|
||||
@@ -187,15 +362,25 @@ class AlarmEngine @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun rescheduleLocked(): ScheduledFire? {
|
||||
/**
|
||||
* [deferUpcomingTo] collects what the upcoming notices would be synced from
|
||||
* instead of syncing them, for a caller with something more urgent to do
|
||||
* first (the fire path).
|
||||
*/
|
||||
private suspend fun rescheduleLocked(deferUpcomingTo: MutableList<UpcomingSync>? = null): ScheduledFire? {
|
||||
val now = wallClock.now()
|
||||
val zone = zones.current()
|
||||
val silenceAfter = settings.currentDefaults().alarmSilenceAfter
|
||||
|
||||
var earliest: Pair<Alarm, AlarmResolution>? = null
|
||||
val resolved = mutableListOf<UpcomingAlarm>()
|
||||
val pendingSkips = mutableListOf<Instant>()
|
||||
for (alarm in alarms.alarms().first()) {
|
||||
val state = states.state(alarm.id)
|
||||
val resolution = AlarmResolver.resolve(alarm, state, now, zone)
|
||||
val resolution = AlarmResolver.resolve(alarm, state, now, zone, silenceAfter)
|
||||
persist(alarm, state, resolution)
|
||||
resolved += UpcomingAlarm(alarm, resolution.nextFire, resolution.source)
|
||||
resolution.state.skippedOccurrence?.let { pendingSkips += it }
|
||||
|
||||
val fireAt = resolution.nextFire ?: continue
|
||||
val best = earliest
|
||||
@@ -205,21 +390,78 @@ class AlarmEngine @Inject constructor(
|
||||
if (isEarlier) earliest = alarm to resolution
|
||||
}
|
||||
|
||||
val winner = earliest ?: run {
|
||||
val winner = earliest
|
||||
val scheduled = if (winner == null) {
|
||||
scheduler.cancelNext()
|
||||
return null
|
||||
null
|
||||
} else {
|
||||
val fireAt = winner.second.nextFire!!
|
||||
val mode = scheduler.scheduleNext(fireAt, winner.first.id)
|
||||
ScheduledFire(winner.first.id, fireAt, winner.second.source, mode)
|
||||
}
|
||||
val fireAt = winner.second.nextFire!!
|
||||
val mode = scheduler.scheduleNext(fireAt, winner.first.id)
|
||||
return ScheduledFire(winner.first.id, fireAt, winner.second.source, mode)
|
||||
// After the real registration, never before it.
|
||||
val upcoming = UpcomingSync(resolved, pendingSkips)
|
||||
if (deferUpcomingTo == null) syncUpcomingLocked(upcoming, now) else deferUpcomingTo += upcoming
|
||||
return scheduled
|
||||
}
|
||||
|
||||
/**
|
||||
* The upcoming-alarm notices and the wake-up for the next window (#16).
|
||||
* Every path that moves an alarm — an edit, a delete, a switch, a skip, a
|
||||
* snooze, a fire, a boot, a clock or zone change — already ends in
|
||||
* [rescheduleLocked], so this is the one place they are kept true.
|
||||
*
|
||||
* Nothing here may cost an alarm, so a failure is swallowed rather than
|
||||
* thrown into whichever entry point is rescheduling.
|
||||
*/
|
||||
private fun syncUpcomingLocked(upcoming: UpcomingSync, now: Instant) {
|
||||
runCatching {
|
||||
val plan = UpcomingNotices.plan(upcoming.resolved, now, upcoming.pendingSkips)
|
||||
notifier.showUpcoming(plan.notices)
|
||||
val next = plan.nextCheckAt
|
||||
if (next == null) scheduler.cancelUpcomingCheck() else scheduler.scheduleUpcomingCheck(next)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Dismissing a repeating alarm only prevents the upcoming instance from
|
||||
* ringing"; dismissing a single occurrence "effectively disables" it.
|
||||
*/
|
||||
private suspend fun dismissComingLocked(alarm: Alarm): DismissOutcome {
|
||||
if (alarm.repeatDays.isRepeating) {
|
||||
// Persist the resolution first: an earlier skip that has gone by but
|
||||
// not yet been spent in storage would otherwise absorb this one,
|
||||
// and the occurrence just dismissed would ring.
|
||||
rescheduleLocked()
|
||||
alarms.setSkipNextOccurrence(alarm.id, true)
|
||||
rescheduleLocked()
|
||||
return DismissOutcome.SkippedNext(alarm.id)
|
||||
}
|
||||
|
||||
disableOrDeleteLocked(alarm)
|
||||
rescheduleLocked()
|
||||
return DismissOutcome.Disabled(alarm.id)
|
||||
}
|
||||
|
||||
private suspend fun onFireLocked(alarmId: Long, scheduledFor: Instant): FireOutcome {
|
||||
val now = wallClock.now()
|
||||
val alarm = alarms.find(alarmId)
|
||||
// Deleted, switched off, or a PendingIntent so stale the moment has gone:
|
||||
// re-resolve the slot and stay quiet.
|
||||
if (alarm == null || !alarm.enabled || now - scheduledFor > AlarmRing.FIRE_GRACE) {
|
||||
val state = states.state(alarmId)
|
||||
val isSnoozeFire = state.snoozedUntil != null && state.snoozedUntil == scheduledFor
|
||||
val silenceAfter = settings.currentDefaults().alarmSilenceAfter
|
||||
// A snooze is kept for as long as the resolver keeps it pending, so one
|
||||
// delivered late (after a reboot, or by Doze) still rings. Rejecting it
|
||||
// here would only re-register the same past instant and fire again.
|
||||
val lateBy = if (isSnoozeFire) AlarmRing.snoozeGrace(silenceAfter) else AlarmRing.FIRE_GRACE
|
||||
// A skip armed on this very occurrence after its fire was already on its
|
||||
// way: the user dismissed it in advance (#16), so it must not ring.
|
||||
// Only the armed skip, not the handled-occurrence watermark: the
|
||||
// resolver deliberately re-arms a handled occurrence that a clock moved
|
||||
// backwards has put back in the future, and that one must ring.
|
||||
val skipped = !isSnoozeFire && scheduledFor == state.skippedOccurrence
|
||||
// Deleted, switched off, skipped, or a PendingIntent so stale the moment
|
||||
// has gone: re-resolve the slot and stay quiet.
|
||||
if (alarm == null || !alarm.enabled || skipped || now - scheduledFor > lateBy) {
|
||||
rescheduleLocked()
|
||||
return FireOutcome.Ignored
|
||||
}
|
||||
@@ -227,33 +469,48 @@ class AlarmEngine @Inject constructor(
|
||||
// At most one alarm rings, and the newly-firing one takes over. The one
|
||||
// it displaces keeps its handled-occurrence watermark, so it does not
|
||||
// come back the moment the slot is re-resolved.
|
||||
// Its cycle has closed undismissed, so a one-shot alarm is switched off
|
||||
// as on every other such close, rather than armed for tomorrow.
|
||||
states.ringing()?.takeIf { it.alarmId != alarmId }?.let { other ->
|
||||
ring.stopRinging(other.alarmId)
|
||||
states.save(other.copy(ringingSince = null, snoozedUntil = null, snoozeCount = 0))
|
||||
alarms.find(other.alarmId)?.takeIf { !it.repeatDays.isRepeating }?.let { disableOrDeleteLocked(it) }
|
||||
}
|
||||
|
||||
val state = states.state(alarmId)
|
||||
val isSnoozeFire = state.snoozedUntil != null && state.snoozedUntil == scheduledFor
|
||||
// `ringingSince` is cleared too: this fire opens the ring afresh, and a
|
||||
// stale value left behind would let the re-resolution below close (and
|
||||
// switch off) the very alarm that is about to ring.
|
||||
val opened = if (isSnoozeFire) {
|
||||
// The cycle is the same one: the count and the watermark carry over.
|
||||
state.copy(snoozedUntil = null)
|
||||
state.copy(ringingSince = null, snoozedUntil = null)
|
||||
} else {
|
||||
state.copy(snoozedUntil = null, snoozeCount = 0, handledOccurrence = scheduledFor)
|
||||
state.copy(ringingSince = null, snoozedUntil = null, snoozeCount = 0, handledOccurrence = scheduledFor)
|
||||
}
|
||||
// The watermark is written before anything else can interrupt, and the
|
||||
// slot is re-resolved while the alarm is not yet marked as ringing — so
|
||||
// it holds this alarm's *next* occurrence rather than nothing at all.
|
||||
states.save(opened)
|
||||
notifier.clearSnoozed(alarmId)
|
||||
rescheduleLocked()
|
||||
// The upcoming notices wait until the ring has started: their binder
|
||||
// calls must not stand between the fire and the sound.
|
||||
val upcoming = mutableListOf<UpcomingSync>()
|
||||
rescheduleLocked(deferUpcomingTo = upcoming)
|
||||
|
||||
states.save(states.state(alarmId).copy(ringingSince = now))
|
||||
ring.startRinging(alarmId)
|
||||
scheduler.scheduleAutoSilence(now + AlarmRing.AUTO_SILENCE_AFTER, alarmId)
|
||||
try {
|
||||
states.save(states.state(alarmId).copy(ringingSince = now))
|
||||
ring.startRinging(alarmId)
|
||||
scheduler.scheduleAutoSilence(now + silenceAfter, alarmId)
|
||||
} finally {
|
||||
upcoming.forEach { syncUpcomingLocked(it, now) }
|
||||
}
|
||||
return FireOutcome.Ringing(alarmId)
|
||||
}
|
||||
|
||||
private suspend fun snoozeLocked(alarmId: Long, minutesOverride: Int? = null): SnoozeOutcome {
|
||||
private suspend fun snoozeLocked(
|
||||
alarmId: Long,
|
||||
minutesOverride: Int?,
|
||||
dismissIfRefused: Boolean,
|
||||
): SnoozeOutcome {
|
||||
val state = states.state(alarmId)
|
||||
if (state.ringingSince == null) return SnoozeOutcome.NotRinging
|
||||
val alarm = alarms.find(alarmId) ?: return SnoozeOutcome.NotRinging
|
||||
@@ -261,7 +518,11 @@ class AlarmEngine @Inject constructor(
|
||||
val resolved = alarm.resolveSettings(settings.currentDefaults())
|
||||
// A limit of 0 means snoozing is off, not unlimited — and a refused
|
||||
// snooze dismisses, so the dismiss path can never strand the user.
|
||||
// The one exception is a caller that asked to keep ringing (a blind
|
||||
// volume-key press, #18): it is told so and must keep the alarm's own
|
||||
// way out on screen.
|
||||
if (state.snoozeCount >= resolved.snoozeLimit) {
|
||||
if (!dismissIfRefused) return SnoozeOutcome.Refused(alarmId)
|
||||
closeCycleLocked(alarmId, state)
|
||||
return SnoozeOutcome.LimitReached(alarmId)
|
||||
}
|
||||
@@ -285,25 +546,68 @@ class AlarmEngine @Inject constructor(
|
||||
|
||||
private suspend fun resumeRingingIfAnyLocked(): Long? {
|
||||
val state = states.ringing() ?: return null
|
||||
val since = state.ringingSince ?: return null
|
||||
// Slept through it: leave it to the next resolution to close the cycle.
|
||||
if (wallClock.now() - since >= AlarmRing.AUTO_SILENCE_AFTER) return null
|
||||
val alarm = alarms.find(state.alarmId)?.takeIf { it.enabled } ?: return null
|
||||
// Slept through it: leave it to the next resolution to close the cycle.
|
||||
if (!rearmIfInsideWindowLocked(state)) return null
|
||||
|
||||
ring.startRinging(alarm.id)
|
||||
// The backstop is measured from when the ring started, not from now, so
|
||||
// a reboot does not buy the alarm another ten minutes.
|
||||
scheduler.scheduleAutoSilence(since + AlarmRing.AUTO_SILENCE_AFTER, alarm.id)
|
||||
return alarm.id
|
||||
}
|
||||
|
||||
/** Everything the resolver asked to be written, and nothing it did not. */
|
||||
/**
|
||||
* The one rule for measuring a ring against "Silence after": still inside
|
||||
* the window ⇒ the backstop is (re-)armed at its end and this returns true;
|
||||
* run out ⇒ false, and the caller closes or drops it. Measured from when the
|
||||
* ring started, so neither a reboot nor a setting change buys it a fresh
|
||||
* window.
|
||||
*
|
||||
* A clock moved back behind that start is re-anchored to now and stored, so
|
||||
* a negative age never lingers for a later re-measure to read as "a whole
|
||||
* window left": a clock moved back adds at most the time already rung, once.
|
||||
*/
|
||||
private suspend fun rearmIfInsideWindowLocked(state: AlarmRingState): Boolean {
|
||||
val stored = state.ringingSince ?: return false
|
||||
val now = wallClock.now()
|
||||
val since = if (now < stored) {
|
||||
states.save(state.copy(ringingSince = now))
|
||||
now
|
||||
} else {
|
||||
stored
|
||||
}
|
||||
val silenceAfter = settings.currentDefaults().alarmSilenceAfter
|
||||
if (now - since >= silenceAfter) return false
|
||||
scheduler.scheduleAutoSilence(since + silenceAfter, state.alarmId)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the resolver asked to be written, and nothing it did not —
|
||||
* plus stopping the sound of a ring it closed, which storage alone cannot do.
|
||||
*/
|
||||
private suspend fun persist(alarm: Alarm, before: AlarmRingState, resolution: AlarmResolution) {
|
||||
if (resolution.state != before) states.save(resolution.state)
|
||||
// A snooze the resolver dropped (it never fired, or the alarm was
|
||||
// switched off) takes its "Snoozed until" notification with it.
|
||||
if (before.snoozedUntil != null && resolution.state.snoozedUntil == null) notifier.clearSnoozed(alarm.id)
|
||||
// The resolver closed a ring: its window ran out before the backstop
|
||||
// fired ("Silence after" was shortened mid-ring, or the clock jumped),
|
||||
// or the alarm was switched off while ringing. Storage now says it is
|
||||
// not ringing, so the sound stops with it rather than waiting for a
|
||||
// backstop registered against the old window.
|
||||
if (before.ringingSince != null && resolution.state.ringingSince == null) {
|
||||
ring.stopRinging(alarm.id)
|
||||
scheduler.cancelAutoSilence()
|
||||
}
|
||||
if (resolution.clearSkipFlag) alarms.setSkipNextOccurrence(alarm.id, false)
|
||||
// A transient alarm is deleted wherever a permanent one would be
|
||||
// disabled: the cycle has closed either way (M9 D10).
|
||||
if (resolution.disableAlarm) disableOrDeleteLocked(alarm)
|
||||
// Conditional on the stored value still being the one resolved, so an
|
||||
// editor that has just picked a new date or pause keeps it.
|
||||
val date = alarm.scheduledDate
|
||||
if (resolution.clearScheduledDate && date != null) alarms.clearScheduledDate(alarm.id, date)
|
||||
val pause = alarm.pause
|
||||
if (resolution.clearPause && pause != null) alarms.clearPause(alarm.id, pause)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,17 +632,47 @@ class AlarmEngine @Inject constructor(
|
||||
* with its backstop intact.
|
||||
*/
|
||||
private suspend fun closeCycleLocked(alarmId: Long, state: AlarmRingState) {
|
||||
if (state.ringingSince != null) {
|
||||
ring.stopRinging(alarmId)
|
||||
scheduler.cancelAutoSilence()
|
||||
}
|
||||
notifier.clearSnoozed(alarmId)
|
||||
silenceLocked(alarmId, state)
|
||||
|
||||
val closed = state.copy(ringingSince = null, snoozedUntil = null, snoozeCount = 0)
|
||||
if (closed != state) states.save(closed)
|
||||
|
||||
val alarm = alarms.find(alarmId)
|
||||
if (alarm != null && !alarm.repeatDays.isRepeating) disableOrDeleteLocked(alarm)
|
||||
if (alarm != null && !alarm.repeatDays.isRepeating) {
|
||||
val zone = zones.current()
|
||||
// A one-shot given a *later* date while it was snoozed still has
|
||||
// that date to ring on, so this cycle closing does not switch it
|
||||
// off (#21). A date on the day that just rang is the one this
|
||||
// cycle used, whatever its time now says.
|
||||
val ahead = AlarmSchedule.upcomingDate(alarm, wallClock.now(), zone)
|
||||
val rangOn = state.handledOccurrence?.let { AlarmSchedule.today(it, zone) }
|
||||
if (ahead == null || (rangOn != null && !ahead.isAfter(rangOn))) {
|
||||
disableOrDeleteLocked(alarm)
|
||||
// The date was spent on this ring: an alarm switched back on
|
||||
// later is a plain one-shot, not "Scheduled for" a past day.
|
||||
alarm.scheduledDate?.let { alarms.clearScheduledDate(alarm.id, it) }
|
||||
}
|
||||
}
|
||||
rescheduleLocked()
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything audible or visible an alarm's cycle holds: the ring service and
|
||||
* its backstop if it is the one ringing, and its snooze notification. The
|
||||
* one teardown, for closing a cycle and for deleting the alarm.
|
||||
*/
|
||||
private fun silenceLocked(alarmId: Long, state: AlarmRingState) {
|
||||
if (state.ringingSince != null) {
|
||||
ring.stopRinging(alarmId)
|
||||
scheduler.cancelAutoSilence()
|
||||
}
|
||||
notifier.clearSnoozed(alarmId)
|
||||
}
|
||||
}
|
||||
|
||||
/** What one reschedule pass hands the upcoming-alarm sync (#16). */
|
||||
private class UpcomingSync(
|
||||
val resolved: List<UpcomingAlarm>,
|
||||
/** Armed skips' watermarks, so a check can spend each one on time. */
|
||||
val pendingSkips: List<Instant>,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,15 @@ object AlarmIntents {
|
||||
const val ACTION_SNOOZE: String = PREFIX + "action.ALARM_SNOOZE"
|
||||
const val ACTION_DISMISS: String = PREFIX + "action.ALARM_DISMISS"
|
||||
|
||||
/** The upcoming-alarm window opening: re-resolve and post the notices (#16). */
|
||||
const val ACTION_UPCOMING_CHECK: String = PREFIX + "action.ALARM_UPCOMING_CHECK"
|
||||
|
||||
/** The upcoming notification's Dismiss, which carries [EXTRA_FIRE_AT] (#16). */
|
||||
const val ACTION_DISMISS_UPCOMING: String = PREFIX + "action.ALARM_DISMISS_UPCOMING"
|
||||
|
||||
/** The user swiped the upcoming notification away; it carries [EXTRA_FIRE_AT] (#16). */
|
||||
const val ACTION_UPCOMING_SWIPED: String = PREFIX + "action.ALARM_UPCOMING_SWIPED"
|
||||
|
||||
/** Internal: "show me the alarms tab", the transport for `SHOW_ALARMS` and the next-alarm show intent. */
|
||||
const val ACTION_SHOW_ALARMS: String = PREFIX + "action.SHOW_ALARMS"
|
||||
|
||||
@@ -25,6 +34,11 @@ object AlarmIntents {
|
||||
const val REQUEST_SHOW: Int = 3
|
||||
const val REQUEST_SNOOZE: Int = 4
|
||||
const val REQUEST_DISMISS: Int = 5
|
||||
const val REQUEST_REPOST: Int = 6
|
||||
const val REQUEST_UPCOMING_CHECK: Int = 7
|
||||
const val REQUEST_DISMISS_UPCOMING: Int = 8
|
||||
const val REQUEST_SHOW_UPCOMING: Int = 9
|
||||
const val REQUEST_UPCOMING_SWIPED: Int = 10
|
||||
|
||||
val ALL_REQUEST_CODES: List<Int> = listOf(
|
||||
REQUEST_FIRE,
|
||||
@@ -32,5 +46,10 @@ object AlarmIntents {
|
||||
REQUEST_SHOW,
|
||||
REQUEST_SNOOZE,
|
||||
REQUEST_DISMISS,
|
||||
REQUEST_REPOST,
|
||||
REQUEST_UPCOMING_CHECK,
|
||||
REQUEST_DISMISS_UPCOMING,
|
||||
REQUEST_SHOW_UPCOMING,
|
||||
REQUEST_UPCOMING_SWIPED,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
package de.jeanlucmakiola.clockula.alarm
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingNotice
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** The snoozed notification — the one surface the engine posts directly. */
|
||||
/** The snoozed and upcoming notifications — the surfaces the engine posts directly. */
|
||||
interface AlarmNotifier {
|
||||
fun showSnoozed(alarm: Alarm, until: Instant)
|
||||
|
||||
fun clearSnoozed(alarmId: Long)
|
||||
|
||||
/**
|
||||
* Makes the upcoming-alarm notifications exactly [notices]: posts the new
|
||||
* ones and removes any that are no longer due (#16). Never throws.
|
||||
*/
|
||||
fun showUpcoming(notices: List<UpcomingNotice>)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package de.jeanlucmakiola.clockula.alarm
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.ScheduleMode
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** The AlarmManager seam. Two slots, deliberately separate. */
|
||||
/** The AlarmManager seam. Three slots, deliberately separate. */
|
||||
interface AlarmScheduler {
|
||||
/** Replaces the single next-alarm registration. Returns the mode actually used. */
|
||||
fun scheduleNext(fireAt: Instant, alarmId: Long): ScheduleMode
|
||||
@@ -14,6 +14,15 @@ interface AlarmScheduler {
|
||||
|
||||
fun cancelAutoSilence()
|
||||
|
||||
/**
|
||||
* When the next upcoming-alarm window opens (#16). Its own PendingIntent and
|
||||
* request code, never `setAlarmClock`: a housekeeping wake-up must not
|
||||
* replace what the system shows as the user's next alarm.
|
||||
*/
|
||||
fun scheduleUpcomingCheck(at: Instant)
|
||||
|
||||
fun cancelUpcomingCheck()
|
||||
|
||||
/** `AlarmManager.getNextAlarmClock()`, as the *system* sees it. */
|
||||
fun systemNextAlarm(): Instant?
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.clockula.alarm.android
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
@@ -9,15 +10,18 @@ import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmNotifier
|
||||
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
|
||||
import de.jeanlucmakiola.clockula.alarm.ring.UpcomingNotifications
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingNotice
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The snoozed notification — the one surface the engine posts directly. A denied
|
||||
* notification permission costs the user this line and nothing else: the snooze
|
||||
* itself is a row in `alarm_states` and an AlarmManager registration.
|
||||
* The snoozed and upcoming notifications — the surfaces the engine posts
|
||||
* directly. A denied notification permission costs the user these lines and
|
||||
* nothing else: the snooze itself is a row in `alarm_states` and an
|
||||
* AlarmManager registration, and the upcoming Dismiss is also on the alarm row.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidAlarmNotifier @Inject constructor(
|
||||
@@ -47,4 +51,52 @@ class AndroidAlarmNotifier @Inject constructor(
|
||||
override fun clearSnoozed(alarmId: Long) {
|
||||
notifications.cancel(RingNotifications.snoozedNotificationId(alarmId))
|
||||
}
|
||||
|
||||
/**
|
||||
* What this process last posted, per alarm, so an unchanged notice on
|
||||
* screen is not re-sent. The engine calls [showUpcoming] under its lock, so
|
||||
* the map needs no lock of its own.
|
||||
*/
|
||||
private val lastPosted = mutableMapOf<Long, UpcomingNotice>()
|
||||
|
||||
override fun showUpcoming(notices: List<UpcomingNotice>) {
|
||||
runCatching {
|
||||
val wantedIds = notices.map { UpcomingNotifications.notificationId(it.alarm.id) }.toSet()
|
||||
// What is on screen, including what an earlier process posted.
|
||||
val onScreen = context.getSystemService(NotificationManager::class.java)
|
||||
.activeNotifications
|
||||
.filter { it.tag == UpcomingNotifications.TAG }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
// Also what this process posted: a notify is asynchronous, so one
|
||||
// posted a moment ago may not be listed as active yet.
|
||||
(onScreen + lastPosted.keys.map(UpcomingNotifications::notificationId))
|
||||
.filterNot { it in wantedIds }
|
||||
.forEach { notifications.cancel(UpcomingNotifications.TAG, it) }
|
||||
lastPosted.keys.retainAll(notices.map { it.alarm.id }.toSet())
|
||||
|
||||
// A swipe is the user's word for that occurrence; the record keeps
|
||||
// only the occurrences still noticed.
|
||||
val stillSwiped = UpcomingNotifications.retainSwiped(
|
||||
context,
|
||||
notices.map(UpcomingNotifications::key).toSet(),
|
||||
)
|
||||
|
||||
// POST_NOTIFICATIONS is a runtime permission from API 33. Denied, the
|
||||
// user loses the notice and keeps the alarm and the row's Dismiss.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return@runCatching
|
||||
}
|
||||
for (notice in notices) {
|
||||
if (UpcomingNotifications.key(notice) in stillSwiped) continue
|
||||
val id = UpcomingNotifications.notificationId(notice.alarm.id)
|
||||
if (id in onScreen && lastPosted[notice.alarm.id] == notice) continue
|
||||
notifications.notify(UpcomingNotifications.TAG, id, UpcomingNotifications.build(context, notice))
|
||||
lastPosted[notice.alarm.id] = notice
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-2
@@ -17,11 +17,13 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Two AlarmManager slots, deliberately separate. The user's next alarm goes
|
||||
* Three AlarmManager slots, deliberately separate. The user's next alarm goes
|
||||
* through `setAlarmClock`, which is the only variant that populates
|
||||
* `getNextAlarmClock()` — the status-bar icon and the lockscreen line. The
|
||||
* auto-silence backstop goes through its own PendingIntent, so an internal
|
||||
* housekeeping time can never overwrite what the system shows the user.
|
||||
* housekeeping time can never overwrite what the system shows the user. The
|
||||
* upcoming-alarm check (#16) is the third, on its own PendingIntent for the
|
||||
* same reason.
|
||||
*
|
||||
* Translation only: which mode to use is [SchedulingPolicy]'s decision.
|
||||
*/
|
||||
@@ -64,6 +66,27 @@ class AndroidAlarmScheduler @Inject constructor(
|
||||
|
||||
override fun cancelAutoSilence() = manager.cancel(autoSilencePendingIntent(alarmId = 0L))
|
||||
|
||||
/**
|
||||
* Exact where allowed, so the notice is up when its window opens; inexact
|
||||
* otherwise, since a late notice costs nothing an alarm needs. Never
|
||||
* `setAlarmClock`, and wrapped whole: nothing about the notice may throw
|
||||
* into the engine's reschedule (#16).
|
||||
*/
|
||||
override fun scheduleUpcomingCheck(at: Instant) {
|
||||
val pendingIntent = upcomingCheckPendingIntent()
|
||||
val millis = at.toEpochMilliseconds()
|
||||
runCatching {
|
||||
val exact = runCatching {
|
||||
manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent)
|
||||
}.isSuccess
|
||||
if (!exact) manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancelUpcomingCheck() {
|
||||
runCatching { manager.cancel(upcomingCheckPendingIntent()) }
|
||||
}
|
||||
|
||||
override fun systemNextAlarm(): Instant? =
|
||||
manager.nextAlarmClock?.let { Instant.fromEpochMilliseconds(it.triggerTime) }
|
||||
|
||||
@@ -94,6 +117,15 @@ class AndroidAlarmScheduler @Inject constructor(
|
||||
PENDING_INTENT_FLAGS,
|
||||
)
|
||||
|
||||
private fun upcomingCheckPendingIntent(): PendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
AlarmIntents.REQUEST_UPCOMING_CHECK,
|
||||
Intent(context, AlarmFireReceiver::class.java).apply {
|
||||
action = AlarmIntents.ACTION_UPCOMING_CHECK
|
||||
},
|
||||
PENDING_INTENT_FLAGS,
|
||||
)
|
||||
|
||||
/**
|
||||
* What the system opens when the user taps the status-bar alarm icon or the
|
||||
* lockscreen's next-alarm line: the **alarms list**, which is what "show me
|
||||
|
||||
+17
-1
@@ -1,9 +1,13 @@
|
||||
package de.jeanlucmakiola.clockula.alarm.android
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
|
||||
import de.jeanlucmakiola.clockula.alarm.RingCoordinator
|
||||
import de.jeanlucmakiola.clockula.alarm.ring.AlarmRingService
|
||||
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.RingPresentationPolicy
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -18,6 +22,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class ServiceRingCoordinator @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val capabilities: AlarmCapabilities,
|
||||
) : RingCoordinator {
|
||||
|
||||
private val state = MutableStateFlow<Long?>(null)
|
||||
@@ -26,11 +31,22 @@ class ServiceRingCoordinator @Inject constructor(
|
||||
|
||||
override fun startRinging(alarmId: Long) {
|
||||
state.value = alarmId
|
||||
AlarmRingService.start(context)
|
||||
// Only an exact alarm exempts the start from Android 12's background
|
||||
// limits. When the inexact fallback fired instead the start can be
|
||||
// refused, and the ring screen is then the one thing left to show.
|
||||
val started = runCatching { AlarmRingService.start(context) }.isSuccess
|
||||
if (!started) {
|
||||
context.getSystemService(NotificationManager::class.java).notify(
|
||||
RingNotifications.RING_NOTIFICATION_ID,
|
||||
RingNotifications.refused(context, RingPresentationPolicy.forCapabilities(capabilities.snapshot())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun stopRinging(alarmId: Long) {
|
||||
if (state.value == alarmId) state.value = null
|
||||
AlarmRingService.stop(context)
|
||||
// The notification a refused start posted has no service to take it down.
|
||||
context.getSystemService(NotificationManager::class.java).cancel(RingNotifications.RING_NOTIFICATION_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
||||
import de.jeanlucmakiola.clockula.alarm.ring.UpcomingNotifications
|
||||
import de.jeanlucmakiola.floret.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/** The notification's Snooze and Dismiss buttons. */
|
||||
/** The notifications' Snooze and Dismiss buttons, the upcoming notice's included. */
|
||||
@AndroidEntryPoint
|
||||
class AlarmActionReceiver : HiltBroadcastReceiver() {
|
||||
|
||||
@@ -26,12 +28,21 @@ class AlarmActionReceiver : HiltBroadcastReceiver() {
|
||||
|
||||
val alarmId = intent.getLongExtra(AlarmIntents.EXTRA_ALARM_ID, 0L)
|
||||
val action = intent.action ?: return
|
||||
if (action == AlarmIntents.ACTION_UPCOMING_SWIPED) {
|
||||
// Bookkeeping only: the alarm itself is untouched.
|
||||
UpcomingNotifications.recordSwiped(context, alarmId, intent.getLongExtra(AlarmIntents.EXTRA_FIRE_AT, 0L))
|
||||
return
|
||||
}
|
||||
val pendingResult = goAsync()
|
||||
scope.launch {
|
||||
try {
|
||||
when (action) {
|
||||
AlarmIntents.ACTION_SNOOZE -> engine.snooze(alarmId)
|
||||
AlarmIntents.ACTION_DISMISS -> engine.dismiss(alarmId)
|
||||
AlarmIntents.ACTION_DISMISS_UPCOMING -> engine.dismissUpcomingOccurrence(
|
||||
alarmId = alarmId,
|
||||
fireAt = Instant.fromEpochMilliseconds(intent.getLongExtra(AlarmIntents.EXTRA_FIRE_AT, 0L)),
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
||||
import de.jeanlucmakiola.clockula.ring.HandoffWakeLock
|
||||
import de.jeanlucmakiola.floret.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -12,8 +13,9 @@ import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The two AlarmManager slots arriving. Thin by design: every decision the engine
|
||||
* could make is the engine's, so the receiver only unpacks and dispatches.
|
||||
* The AlarmManager slots arriving: the fire, the backstop and the upcoming
|
||||
* check. Thin by design: every decision the engine could make is the
|
||||
* engine's, so the receiver only unpacks and dispatches.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class AlarmFireReceiver : HiltBroadcastReceiver() {
|
||||
@@ -32,6 +34,7 @@ class AlarmFireReceiver : HiltBroadcastReceiver() {
|
||||
|
||||
val alarmId = intent.getLongExtra(AlarmIntents.EXTRA_ALARM_ID, 0L)
|
||||
val action = intent.action ?: return
|
||||
if (action == AlarmIntents.ACTION_FIRE) HandoffWakeLock.acquire(context, "clockula:alarm-fire")
|
||||
val pendingResult = goAsync()
|
||||
scope.launch {
|
||||
try {
|
||||
@@ -46,6 +49,8 @@ class AlarmFireReceiver : HiltBroadcastReceiver() {
|
||||
)
|
||||
|
||||
AlarmIntents.ACTION_AUTO_SILENCE -> engine.onAutoSilence(alarmId)
|
||||
|
||||
AlarmIntents.ACTION_UPCOMING_CHECK -> engine.onUpcomingWindow()
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
|
||||
@@ -5,10 +5,13 @@ import android.content.Intent
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
|
||||
import de.jeanlucmakiola.clockula.tile.requestAllTileUpdates
|
||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||
import de.jeanlucmakiola.clockula.widget.AnalogClockWidget
|
||||
import de.jeanlucmakiola.clockula.widget.AnalogDateRefresh
|
||||
import de.jeanlucmakiola.clockula.widget.MidnightRefresh
|
||||
import de.jeanlucmakiola.clockula.widget.NextAlarmWidget
|
||||
import de.jeanlucmakiola.floret.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -17,9 +20,11 @@ import javax.inject.Inject
|
||||
/**
|
||||
* Everything that invalidates a registration: a reboot clears AlarmManager, an
|
||||
* app update clears it too, and a clock or zone change moves what "07:00" means.
|
||||
* All four are answered the same way — re-resolve from now.
|
||||
* All four are answered the same way — re-resolve from now. So is the user
|
||||
* granting "Alarms & reminders" again, which turns an inexact registration
|
||||
* back into an exact one.
|
||||
*
|
||||
* Both engines are told, in that order. For the timers a clock change
|
||||
* Both engines are told, in that order, and the bedtime schedule after them. For the timers a clock change
|
||||
* reschedules nothing (their slot is elapsed-realtime anchored); it re-anchors
|
||||
* the wall-clock fallback, which is what the notification's chronometer counts
|
||||
* to and what a reboot would fall back on.
|
||||
@@ -39,6 +44,9 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
|
||||
@Inject
|
||||
lateinit var stopwatchEngine: StopwatchEngine
|
||||
|
||||
@Inject
|
||||
lateinit var bedtimeEngine: BedtimeEngine
|
||||
|
||||
@Inject
|
||||
@ApplicationScope
|
||||
lateinit var scope: CoroutineScope
|
||||
@@ -65,6 +73,11 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
|
||||
stopwatchEngine.resync()
|
||||
}
|
||||
|
||||
ACTION_EXACT_ALARM_PERMISSION_CHANGED -> {
|
||||
engine.reschedule()
|
||||
timerEngine.resync()
|
||||
}
|
||||
|
||||
Intent.ACTION_TIME_CHANGED,
|
||||
Intent.ACTION_TIMEZONE_CHANGED,
|
||||
-> {
|
||||
@@ -85,12 +98,28 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
|
||||
// The analog faces' date is drawn for the old day or
|
||||
// zone, and the midnight redraw armed for the old one.
|
||||
AnalogClockWidget().updateAll(context)
|
||||
AnalogDateRefresh.schedule(context)
|
||||
NextAlarmWidget().updateAll(context)
|
||||
MidnightRefresh.schedule(context)
|
||||
// Both tiles show a wall-clock time in the zone they
|
||||
// were drawn in, and the instant behind it need not
|
||||
// have changed, so no write may reach `WidgetSync`.
|
||||
requestAllTileUpdates(context)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Last, and on its own: every one of these events can move or
|
||||
// clear the bedtime slot too, but nothing bedtime does may
|
||||
// delay or break the alarm and timer passes above, and a
|
||||
// failure up there must not cost bedtime its pass either.
|
||||
runCatching { bedtimeEngine.resync() }
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** `AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED`, API 31+. */
|
||||
const val ACTION_EXACT_ALARM_PERMISSION_CHANGED =
|
||||
"android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package de.jeanlucmakiola.clockula.alarm.ring
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -12,16 +14,21 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.RingPresentationPolicy
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
|
||||
import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen
|
||||
import de.jeanlucmakiola.clockula.ring.RingAudioPlayer
|
||||
import de.jeanlucmakiola.clockula.ring.RingVibrator
|
||||
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -43,6 +50,9 @@ class AlarmRingService : Service() {
|
||||
@Inject
|
||||
lateinit var capabilities: AlarmCapabilities
|
||||
|
||||
@Inject
|
||||
lateinit var settings: SettingsPrefs
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
private val audio by lazy { RingAudioPlayer(this) }
|
||||
@@ -51,6 +61,12 @@ class AlarmRingService : Service() {
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var ringing: Job? = null
|
||||
|
||||
/** The alarm this service last asked a ring screen for. */
|
||||
private var screenFor: Long? = null
|
||||
|
||||
/** The notification last posted, put back as it was when the user swipes it away. */
|
||||
private var posted: Notification? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -58,6 +74,16 @@ class AlarmRingService : Service() {
|
||||
stop()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
if (intent?.action == ACTION_REPOST) {
|
||||
// The ring is untouched; only its notification comes back. A
|
||||
// service the system recreated has nothing to repost yet, so it
|
||||
// starts the ring as any other start would.
|
||||
posted?.let {
|
||||
getSystemService(NotificationManager::class.java)
|
||||
.notify(RingNotifications.RING_NOTIFICATION_ID, it)
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
}
|
||||
|
||||
// The whole ring is released, not just the coroutine: cancelling the job
|
||||
// alone would orphan a MediaPlayer the previous start had already
|
||||
@@ -67,6 +93,8 @@ class AlarmRingService : Service() {
|
||||
// foreground service a few seconds to post its notification, and a
|
||||
// database read is not something to spend them on.
|
||||
startForegroundPreparing()
|
||||
// Also before the read: the fire broadcast's own wake lock is gone by now.
|
||||
acquireWakeLock()
|
||||
ringing = scope.launch {
|
||||
val session = engine.ringSession()
|
||||
if (session == null) {
|
||||
@@ -75,6 +103,7 @@ class AlarmRingService : Service() {
|
||||
return@launch
|
||||
}
|
||||
ring(session)
|
||||
openScreen(session)
|
||||
}
|
||||
// Re-delivered rather than dropped: a killed ring is resumed with the
|
||||
// same intent, and the session is re-read from storage anyway.
|
||||
@@ -89,7 +118,6 @@ class AlarmRingService : Service() {
|
||||
|
||||
private fun ring(session: RingSession) {
|
||||
startForeground(session)
|
||||
acquireWakeLock()
|
||||
|
||||
// Vibration first: the sound may take seconds to open when another app
|
||||
// plays it, and the ring must not be still until then.
|
||||
@@ -106,26 +134,56 @@ class AlarmRingService : Service() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the full-screen intent would only show a heads-up. The screen
|
||||
* reads its session once, so one already up is left alone only when it
|
||||
* is this alarm's: a restart must not reset a half-done challenge, but
|
||||
* an alarm that took over needs a screen of its own.
|
||||
*/
|
||||
private suspend fun openScreen(session: RingSession) {
|
||||
val screen = AlarmRingActivity::class.java
|
||||
ForegroundRingScreen.launch(
|
||||
context = this,
|
||||
screen = screen,
|
||||
screenShowing = screenFor == session.alarm.id && ForegroundRingScreen.isShowing(screen),
|
||||
overOtherApps = settings.ringOverOtherApps.first(),
|
||||
)
|
||||
screenFor = session.alarm.id
|
||||
}
|
||||
|
||||
/**
|
||||
* The placeholder the service goes foreground with immediately; [ring]
|
||||
* replaces it with the alarm's own notification as soon as the session is
|
||||
* read. Same channel and same id, so the user sees one notification.
|
||||
* read. Same channel and same id, so the user sees one notification — and
|
||||
* the placeholder is the first post, so it is the one that opens the ring
|
||||
* screen.
|
||||
*/
|
||||
private fun startForegroundPreparing() {
|
||||
startForeground(RingNotifications.preparing(this))
|
||||
startForeground(RingNotifications.preparing(this, presentation(), repostPendingIntent()))
|
||||
}
|
||||
|
||||
private fun startForeground(session: RingSession) {
|
||||
startForeground(
|
||||
RingNotifications.ringing(
|
||||
context = this,
|
||||
alarm = session.alarm,
|
||||
presentation = RingPresentationPolicy.forCapabilities(capabilities.snapshot()),
|
||||
session = session,
|
||||
presentation = presentation(),
|
||||
repost = repostPendingIntent(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun presentation() = RingPresentationPolicy.forCapabilities(capabilities.snapshot())
|
||||
|
||||
private fun repostPendingIntent(): PendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
AlarmIntents.REQUEST_REPOST,
|
||||
Intent(this, AlarmRingService::class.java).setAction(ACTION_REPOST),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun startForeground(notification: Notification) {
|
||||
posted = notification
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// systemExempted: Android's own guidance names an app holding an
|
||||
// exact-alarm permission and continuing an alarm in the background.
|
||||
@@ -159,25 +217,30 @@ class AlarmRingService : Service() {
|
||||
|
||||
private fun stop() {
|
||||
releaseRing()
|
||||
posted = null
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTION_STOP = AlarmIntents.ACTION_DISMISS + ".SERVICE_STOP"
|
||||
private const val ACTION_REPOST = AlarmIntents.ACTION_DISMISS + ".SERVICE_REPOST"
|
||||
private const val WAKE_LOCK_TAG = "clockula:alarm-ring"
|
||||
|
||||
/** A backstop on the backstop: the ring window is ten minutes. */
|
||||
private const val WAKE_LOCK_TIMEOUT_MILLIS = 15L * 60L * 1_000L
|
||||
/** A backstop on the backstop, shared with the other ring service. */
|
||||
private val WAKE_LOCK_TIMEOUT_MILLIS = SilenceAfter.WAKE_LOCK_TIMEOUT.inWholeMilliseconds
|
||||
|
||||
fun start(context: Context) {
|
||||
context.startForegroundService(Intent(context, AlarmRingService::class.java))
|
||||
}
|
||||
|
||||
/**
|
||||
* `stopService` rather than a stop command: a command is a service
|
||||
* start, which throws from the background once the service is gone,
|
||||
* and a dismiss must never fail halfway through its writes.
|
||||
*/
|
||||
fun stop(context: Context) {
|
||||
context.startService(
|
||||
Intent(context, AlarmRingService::class.java).setAction(ACTION_STOP),
|
||||
)
|
||||
context.stopService(Intent(context, AlarmRingService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
||||
import de.jeanlucmakiola.clockula.alarm.receiver.AlarmActionReceiver
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.RingPresentation
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
|
||||
import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen
|
||||
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
|
||||
import java.time.Instant as JavaInstant
|
||||
import java.time.LocalTime
|
||||
@@ -72,9 +75,17 @@ object RingNotifications {
|
||||
* The notification the service goes foreground with the instant it starts,
|
||||
* before it has read anything: `startForeground` must not wait on a database
|
||||
* read. [ringing] replaces it under the same id a moment later.
|
||||
*
|
||||
* It already carries the full-screen intent. SystemUI launches one only
|
||||
* when a notification is first *posted*, never on an update, so an intent
|
||||
* that only [ringing] carried would leave a locked phone ringing behind a
|
||||
* black screen (issue #15). The ring screen reads the session itself and
|
||||
* closes when there is none, so a dismiss that lands before the service's
|
||||
* read costs one brief screen wake, never a ring screen left up.
|
||||
*/
|
||||
fun preparing(context: Context): Notification =
|
||||
NotificationCompat.Builder(context, RING_CHANNEL_ID)
|
||||
fun preparing(context: Context, presentation: RingPresentation, repost: PendingIntent): Notification {
|
||||
val show = showPendingIntent(context)
|
||||
val builder = NotificationCompat.Builder(context, RING_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(context.getString(R.string.alarm_notification_title))
|
||||
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||
@@ -82,15 +93,51 @@ object RingNotifications {
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setContentIntent(showPendingIntent(context))
|
||||
.build()
|
||||
.setContentIntent(show)
|
||||
.setDeleteIntent(repost)
|
||||
if (presentation.useFullScreenIntent) builder.setFullScreenIntent(show, true)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/** The ringing notification, presented as [presentation] says the platform allows. */
|
||||
/**
|
||||
* Posted in place of the service when Android refused to start it: the
|
||||
* ring screen through the full-screen intent where that is allowed, and a
|
||||
* heads-up on the alarm channel where it is not. Better a silent alarm on
|
||||
* screen than none at all.
|
||||
*/
|
||||
fun refused(context: Context, presentation: RingPresentation): Notification {
|
||||
val show = showPendingIntent(context)
|
||||
val builder = NotificationCompat.Builder(context, RING_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(context.getString(R.string.alarm_notification_title))
|
||||
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(show)
|
||||
if (presentation.useFullScreenIntent) builder.setFullScreenIntent(show, true)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* The ringing notification, presented as [presentation] says the platform allows.
|
||||
*
|
||||
* Its actions never get round the ring screen's rules. Snooze appears only
|
||||
* while snoozes are left, since a refused snooze dismisses; and an alarm
|
||||
* with a dismiss challenge has its Dismiss open the ring screen, where the
|
||||
* challenge is, rather than end the alarm from a heads-up or the lock screen.
|
||||
*
|
||||
* From Android 14 a foreground service's notification can be swiped away
|
||||
* on an unlocked phone, ongoing or not. [repost] puts it back, so a
|
||||
* sounding alarm never loses its controls.
|
||||
*/
|
||||
fun ringing(
|
||||
context: Context,
|
||||
alarm: Alarm,
|
||||
session: RingSession,
|
||||
presentation: RingPresentation,
|
||||
repost: PendingIntent,
|
||||
): Notification {
|
||||
val alarm = session.alarm
|
||||
val show = showPendingIntent(context)
|
||||
val builder = NotificationCompat.Builder(context, RING_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
@@ -102,16 +149,20 @@ object RingNotifications {
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setContentIntent(show)
|
||||
.addAction(
|
||||
.setDeleteIntent(repost)
|
||||
if (session.canSnooze) {
|
||||
builder.addAction(
|
||||
R.drawable.ic_notification,
|
||||
context.getString(R.string.alarm_action_snooze),
|
||||
actionPendingIntent(context, AlarmIntents.ACTION_SNOOZE, AlarmIntents.REQUEST_SNOOZE, alarm.id),
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.ic_notification,
|
||||
context.getString(R.string.alarm_action_dismiss),
|
||||
actionPendingIntent(context, AlarmIntents.ACTION_DISMISS, AlarmIntents.REQUEST_DISMISS, alarm.id),
|
||||
)
|
||||
}
|
||||
val dismiss = if (session.settings.dismissChallenge == DismissChallenge.NONE) {
|
||||
actionPendingIntent(context, AlarmIntents.ACTION_DISMISS, AlarmIntents.REQUEST_DISMISS, alarm.id)
|
||||
} else {
|
||||
show
|
||||
}
|
||||
builder.addAction(R.drawable.ic_notification, context.getString(R.string.alarm_action_dismiss), dismiss)
|
||||
|
||||
if (presentation.useFullScreenIntent) builder.setFullScreenIntent(show, true)
|
||||
return builder.build()
|
||||
@@ -140,9 +191,7 @@ object RingNotifications {
|
||||
private fun showPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
AlarmIntents.REQUEST_SHOW,
|
||||
Intent(context, AlarmRingActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
},
|
||||
ForegroundRingScreen.intent(context, AlarmRingActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
@@ -166,7 +215,8 @@ object RingNotifications {
|
||||
private fun formatTime(context: Context, hour: Int, minute: Int): String =
|
||||
format(context, LocalTime.of(hour, minute))
|
||||
|
||||
private fun formatInstant(context: Context, instant: Instant): String = format(
|
||||
/** Also the upcoming notice's (#16): one formatter for every alarm notification. */
|
||||
internal fun formatInstant(context: Context, instant: Instant): String = format(
|
||||
context,
|
||||
JavaInstant.ofEpochMilli(instant.toEpochMilliseconds()).atZone(ZoneId.systemDefault()).toLocalTime(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package de.jeanlucmakiola.clockula.alarm.ring
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import de.jeanlucmakiola.clockula.MainActivity
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
||||
import de.jeanlucmakiola.clockula.alarm.receiver.AlarmActionReceiver
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingNotice
|
||||
|
||||
/**
|
||||
* "Upcoming alarm" with a Dismiss, posted while an alarm is inside its window
|
||||
* (#16). Silent and low-importance: it is a way out for someone already awake,
|
||||
* not a second alarm. Its Dismiss is the engine's `dismissUpcomingOccurrence`
|
||||
* for the one occurrence it names, so it can never end a ring that has already
|
||||
* started; it asks for an unlock first (and below Android 12, where it cannot,
|
||||
* the notice is kept off the secure lock screen), so a locked phone cannot be
|
||||
* talked out of its alarm by whoever picks it up.
|
||||
*/
|
||||
object UpcomingNotifications {
|
||||
|
||||
const val CHANNEL_ID: String = "alarm_upcoming"
|
||||
|
||||
/** Tagged, so the per-alarm ids cannot collide with any other notification's. */
|
||||
const val TAG: String = "alarm_upcoming"
|
||||
|
||||
/** Idempotent; called from the app class on every start. */
|
||||
fun createChannel(context: Context) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.channel_alarm_upcoming_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = context.getString(R.string.channel_alarm_upcoming_description)
|
||||
}
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
fun notificationId(alarmId: Long): Int = alarmId.toInt()
|
||||
|
||||
fun build(context: Context, notice: UpcomingNotice): Notification {
|
||||
val alarm = notice.alarm
|
||||
val time = RingNotifications.formatInstant(context, notice.fireAt)
|
||||
val text = if (alarm.label.isBlank()) {
|
||||
time
|
||||
} else {
|
||||
context.getString(R.string.alarm_upcoming_text_labelled, time, alarm.label)
|
||||
}
|
||||
return NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(context.getString(R.string.alarm_upcoming_title))
|
||||
.setContentText(text)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
// The Dismiss asks for an unlock from Android 12; below that the
|
||||
// platform cannot, so there the notice stays off a locked screen.
|
||||
.setVisibility(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
NotificationCompat.VISIBILITY_PUBLIC
|
||||
} else {
|
||||
NotificationCompat.VISIBILITY_SECRET
|
||||
},
|
||||
)
|
||||
.setOnlyAlertOnce(true)
|
||||
// Not bridged to a watch, where its Dismiss would skip the unlock.
|
||||
.setLocalOnly(true)
|
||||
.setSilent(true)
|
||||
.setShowWhen(false)
|
||||
// Several alarms in the window read in the order they ring.
|
||||
.setSortKey(notice.fireAt.toEpochMilliseconds().toString().padStart(SORT_KEY_WIDTH, '0'))
|
||||
.setContentIntent(showAlarmsPendingIntent(context))
|
||||
.setDeleteIntent(swipedPendingIntent(context, notice))
|
||||
.addAction(
|
||||
NotificationCompat.Action.Builder(
|
||||
R.drawable.ic_notification,
|
||||
context.getString(R.string.alarm_action_dismiss),
|
||||
dismissPendingIntent(context, notice),
|
||||
).setAuthenticationRequired(true).build(),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* The `id@fireAt` notices the user swiped away. Recorded only from the
|
||||
* delete intent, which fires for a swipe or "clear all" and never for the
|
||||
* app's own cancel, a reboot or a blocked channel — so a reschedule, even in
|
||||
* a new process, never brings back a notice the user got rid of, and a
|
||||
* notice the system took away does come back.
|
||||
*/
|
||||
@Synchronized
|
||||
fun recordSwiped(context: Context, alarmId: Long, fireAtMillis: Long) {
|
||||
val keys = read(context) + key(alarmId, fireAtMillis)
|
||||
prefs(context).edit().putStringSet(SWIPED_KEY, keys).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the keys for occurrences no longer in [noticed], so the record stays
|
||||
* small, and returns what is left. One synchronised read-modify-write, so a
|
||||
* swipe recorded from the receiver meanwhile is never overwritten.
|
||||
*/
|
||||
@Synchronized
|
||||
fun retainSwiped(context: Context, noticed: Set<String>): Set<String> {
|
||||
val swiped = read(context)
|
||||
val kept = swiped.intersect(noticed)
|
||||
if (kept != swiped) prefs(context).edit().putStringSet(SWIPED_KEY, kept).apply()
|
||||
return kept
|
||||
}
|
||||
|
||||
private fun read(context: Context): Set<String> =
|
||||
prefs(context).getStringSet(SWIPED_KEY, emptySet()).orEmpty().toSet()
|
||||
|
||||
fun key(notice: UpcomingNotice): String = key(notice.alarm.id, notice.fireAt.toEpochMilliseconds())
|
||||
|
||||
private fun key(alarmId: Long, fireAtMillis: Long): String = "$alarmId@$fireAtMillis"
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(RECORD_FILE, Context.MODE_PRIVATE)
|
||||
|
||||
/** The alarms list, the same door the status-bar alarm icon opens. */
|
||||
private fun showAlarmsPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
AlarmIntents.REQUEST_SHOW_UPCOMING,
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
action = AlarmIntents.ACTION_SHOW_ALARMS
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun dismissPendingIntent(context: Context, notice: UpcomingNotice): PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
// Per alarm, so two notices' Dismiss buttons keep their own extras.
|
||||
AlarmIntents.REQUEST_DISMISS_UPCOMING * REQUEST_CODE_STRIDE +
|
||||
(notice.alarm.id % REQUEST_CODE_STRIDE).toInt(),
|
||||
Intent(context, AlarmActionReceiver::class.java).apply {
|
||||
action = AlarmIntents.ACTION_DISMISS_UPCOMING
|
||||
putExtra(AlarmIntents.EXTRA_ALARM_ID, notice.alarm.id)
|
||||
putExtra(AlarmIntents.EXTRA_FIRE_AT, notice.fireAt.toEpochMilliseconds())
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun swipedPendingIntent(context: Context, notice: UpcomingNotice): PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
AlarmIntents.REQUEST_UPCOMING_SWIPED * REQUEST_CODE_STRIDE +
|
||||
(notice.alarm.id % REQUEST_CODE_STRIDE).toInt(),
|
||||
Intent(context, AlarmActionReceiver::class.java).apply {
|
||||
action = AlarmIntents.ACTION_UPCOMING_SWIPED
|
||||
putExtra(AlarmIntents.EXTRA_ALARM_ID, notice.alarm.id)
|
||||
putExtra(AlarmIntents.EXTRA_FIRE_AT, notice.fireAt.toEpochMilliseconds())
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
/** Excluded from backup and device transfer: it describes this device's shade. */
|
||||
private const val RECORD_FILE = "alarm_upcoming_notices"
|
||||
private const val SWIPED_KEY = "swiped"
|
||||
|
||||
/** Matches `RingNotifications`: keeps a derived request code clear of the small ones. */
|
||||
private const val REQUEST_CODE_STRIDE = 100_000
|
||||
|
||||
private const val SORT_KEY_WIDTH = 20
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
package de.jeanlucmakiola.clockula.backup
|
||||
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
|
||||
import de.jeanlucmakiola.clockula.data.backup.BackupRepository
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
|
||||
import de.jeanlucmakiola.clockula.domain.TimerState
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupContent
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -24,9 +25,9 @@ import javax.inject.Singleton
|
||||
class BackupEngine @Inject constructor(
|
||||
private val repository: BackupRepository,
|
||||
private val settings: SettingsPrefs,
|
||||
private val timers: TimerRepository,
|
||||
private val alarmEngine: AlarmEngine,
|
||||
private val timerEngine: TimerEngine,
|
||||
private val bedtimeEngine: BedtimeEngine,
|
||||
private val wallClock: WallClock,
|
||||
) {
|
||||
|
||||
@@ -39,24 +40,74 @@ class BackupEngine @Inject constructor(
|
||||
settings = settingsSnapshot(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Replaces every alarm, timer and world clock, and every setting the
|
||||
* backup carries. Before the replace: a ringing alarm is dismissed (the
|
||||
* same `stopRingIfRinging` step `AlarmsViewModel` takes before disabling
|
||||
* one) and every running timer is deleted through [TimerEngine], so the
|
||||
* import can never strand a live ring session mid-replace. After it:
|
||||
* both engines are told to re-resolve, so the imported state is live
|
||||
* immediately rather than waiting for the next unrelated trigger.
|
||||
*/
|
||||
/** Replace settings and rows, then reconcile the platform services. */
|
||||
suspend fun import(content: BackupContent) {
|
||||
stopRingingAlarmIfAny()
|
||||
deleteRunningTimers()
|
||||
|
||||
repository.replaceAll(content.alarms, content.timers, content.worldClocks)
|
||||
applySettings(content.settings)
|
||||
|
||||
alarmEngine.reschedule()
|
||||
timerEngine.resync()
|
||||
val previousSettings = settingsSnapshot()
|
||||
var settingsApplied = false
|
||||
var rowsReplaced = false
|
||||
var ringSuspended = false
|
||||
var failure: Throwable? = null
|
||||
try {
|
||||
withContext(NonCancellable) {
|
||||
settings.applyBackup(content.settings)
|
||||
settingsApplied = true
|
||||
}
|
||||
// Pause actuators without deleting their rows. If Room rejects the
|
||||
// replacement, the old ring and running timers can be resumed.
|
||||
alarmEngine.suspendRingForImport()
|
||||
ringSuspended = true
|
||||
timerEngine.suspendForImport()
|
||||
withContext(NonCancellable) {
|
||||
repository.replaceAll(content.alarms, content.timers, content.worldClocks)
|
||||
rowsReplaced = true
|
||||
}
|
||||
} catch (caught: Throwable) {
|
||||
failure = caught
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
if (!rowsReplaced && settingsApplied) {
|
||||
try {
|
||||
settings.applyBackup(previousSettings)
|
||||
} catch (rollbackFailure: Throwable) {
|
||||
failure?.addSuppressed(rollbackFailure)
|
||||
}
|
||||
}
|
||||
// The database transaction either committed in full or left its
|
||||
// old rows. In both cases refresh every platform registration.
|
||||
try {
|
||||
if (!rowsReplaced && ringSuspended) alarmEngine.resumeRingingIfAny()
|
||||
alarmEngine.reschedule()
|
||||
} catch (reconcileFailure: Throwable) {
|
||||
val reported = if (rowsReplaced) {
|
||||
IllegalStateException("backup imported, but alarm scheduling could not be refreshed", reconcileFailure)
|
||||
} else {
|
||||
reconcileFailure
|
||||
}
|
||||
if (failure == null) failure = reported else failure?.addSuppressed(reported)
|
||||
}
|
||||
try {
|
||||
timerEngine.resync()
|
||||
} catch (reconcileFailure: Throwable) {
|
||||
val reported = if (rowsReplaced) {
|
||||
IllegalStateException("backup imported, but timer scheduling could not be refreshed", reconcileFailure)
|
||||
} else {
|
||||
reconcileFailure
|
||||
}
|
||||
if (failure == null) failure = reported else failure?.addSuppressed(reported)
|
||||
}
|
||||
// The bedtime settings came with the rest (or were rolled back
|
||||
// with them), so the slot and the Do Not Disturb rule follow.
|
||||
// Its platform calls are guarded already; a failure here is
|
||||
// not a failed import. The engine compares against what was
|
||||
// stored before (after a rollback, the same again), so only a
|
||||
// real change to the schedule or the rule re-asserts the mode.
|
||||
val previousBedtime = previousSettings.bedtime
|
||||
if (settingsApplied && previousBedtime != null) {
|
||||
runCatching { bedtimeEngine.resyncAfterRestore(previousBedtime) }
|
||||
}
|
||||
}
|
||||
}
|
||||
failure?.let { throw it }
|
||||
}
|
||||
|
||||
private suspend fun settingsSnapshot(): BackupSettings = BackupSettings(
|
||||
@@ -64,29 +115,9 @@ class BackupEngine @Inject constructor(
|
||||
dynamicColor = settings.dynamicColor.first(),
|
||||
defaults = settings.currentDefaults(),
|
||||
timerPresets = settings.timerPresets.first(),
|
||||
worldClockSeconds = settings.worldClockSeconds.first(),
|
||||
screenSaver = settings.screenSaver.first(),
|
||||
quickTileDuration = settings.quickTileDuration.first(),
|
||||
bedtime = settings.currentBedtime(),
|
||||
)
|
||||
|
||||
private suspend fun applySettings(backup: BackupSettings) {
|
||||
settings.setThemeMode(backup.themeMode)
|
||||
settings.setDynamicColor(backup.dynamicColor)
|
||||
settings.setSnoozeMinutes(backup.defaults.snoozeMinutes)
|
||||
settings.setSnoozeLimit(backup.defaults.snoozeLimit)
|
||||
settings.setVibrate(backup.defaults.vibrate)
|
||||
settings.setVolumeRampSeconds(backup.defaults.volumeRampSeconds)
|
||||
settings.setAlarmRingtoneUri(backup.defaults.alarmRingtoneUri)
|
||||
settings.setTimerRingtoneUri(backup.defaults.timerRingtoneUri)
|
||||
settings.setDismissChallenge(backup.defaults.dismissChallenge)
|
||||
settings.setHomeZoneId(backup.defaults.homeZoneId)
|
||||
settings.setTimerPresets(backup.timerPresets)
|
||||
}
|
||||
|
||||
private suspend fun stopRingingAlarmIfAny() {
|
||||
val ringingId = alarmEngine.ringSession()?.alarm?.id ?: return
|
||||
alarmEngine.dismiss(ringingId)
|
||||
}
|
||||
|
||||
private suspend fun deleteRunningTimers() {
|
||||
val runningIds = timers.timers().first().filter { it.state == TimerState.RUNNING }.map { it.id }
|
||||
for (id in runningIds) timerEngine.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ import de.jeanlucmakiola.clockula.backup.BackupFiles
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -27,14 +29,29 @@ class SafBackupFiles @Inject constructor(
|
||||
|
||||
override suspend fun write(treeUri: String, content: String): Result<Unit> = withContext(io) {
|
||||
runCatching {
|
||||
val bytes = content.toByteArray(Charsets.UTF_8)
|
||||
if (bytes.size > MAX_BACKUP_BYTES) throw IOException("backup exceeds the 8 MiB limit")
|
||||
val tree = DocumentFile.fromTreeUri(context, treeUri.toUri())
|
||||
?: error("could not open the chosen folder")
|
||||
tree.findFile(BACKUP_FILE_NAME)?.delete()
|
||||
val file = tree.createFile("application/json", BACKUP_FILE_NAME)
|
||||
?: error("could not create $BACKUP_FILE_NAME")
|
||||
val stream = context.contentResolver.openOutputStream(file.uri)
|
||||
?: throw IOException("could not open an output stream for ${file.uri}")
|
||||
stream.use { it.write(content.toByteArray(Charsets.UTF_8)) }
|
||||
val temporary = tree.createFile("application/json", "$BACKUP_FILE_NAME.${UUID.randomUUID()}.tmp")
|
||||
?: throw IOException("could not create a temporary backup")
|
||||
var keepTemporary = false
|
||||
try {
|
||||
val stream = context.contentResolver.openOutputStream(temporary.uri)
|
||||
?: throw IOException("could not open an output stream for ${temporary.uri}")
|
||||
stream.use { it.write(bytes) }
|
||||
val existing = tree.findFile(BACKUP_FILE_NAME)
|
||||
if (existing != null && !existing.delete()) throw IOException("could not remove the previous backup")
|
||||
// Some SAF providers cannot rename. Once the old file has been
|
||||
// removed, retain the complete temporary file if rename fails.
|
||||
keepTemporary = true
|
||||
if (!temporary.renameTo(BACKUP_FILE_NAME)) {
|
||||
throw IOException("backup was written as ${temporary.name}; the provider could not rename it")
|
||||
}
|
||||
} catch (failure: Throwable) {
|
||||
if (!keepTemporary) runCatching { temporary.delete() }
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +59,23 @@ class SafBackupFiles @Inject constructor(
|
||||
runCatching {
|
||||
val stream = context.contentResolver.openInputStream(documentUri.toUri())
|
||||
?: throw IOException("could not open an input stream for $documentUri")
|
||||
stream.use { it.readBytes().toString(Charsets.UTF_8) }
|
||||
stream.use { input ->
|
||||
val output = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(8192)
|
||||
var total = 0
|
||||
while (true) {
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
total += count
|
||||
if (total > MAX_BACKUP_BYTES) throw IOException("backup exceeds the 8 MiB limit")
|
||||
output.write(buffer, 0, count)
|
||||
}
|
||||
output.toString(Charsets.UTF_8.name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_BACKUP_BYTES = 8 * 1024 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime
|
||||
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSchedule
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.time.ZoneId
|
||||
import kotlin.math.ceil
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The bedtime schedule (issue #1), orchestrated the way the other engines
|
||||
* orchestrate theirs: plain Kotlin, the platform behind three seams.
|
||||
*
|
||||
* It owns one AlarmManager slot, armed for the schedule's next event (the
|
||||
* reminder, bedtime, or the wake time), and one Do Not Disturb rule, which it
|
||||
* creates while the schedule is on and Clockula has Do Not Disturb access,
|
||||
* and removes when the schedule is turned off. Every entry point is an
|
||||
* idempotent pass over "where does the schedule stand right now", so a late,
|
||||
* missed or repeated fire settles into the right state.
|
||||
*
|
||||
* It never touches an alarm: the rule lets alarms through, and its slot is
|
||||
* its own.
|
||||
*/
|
||||
@Singleton
|
||||
class BedtimeEngine @Inject constructor(
|
||||
private val settings: SettingsPrefs,
|
||||
private val scheduler: BedtimeScheduler,
|
||||
private val mode: BedtimeMode,
|
||||
private val notifier: BedtimeNotifier,
|
||||
private val wallClock: WallClock,
|
||||
private val zones: ZoneProvider,
|
||||
) {
|
||||
|
||||
private val lock = Mutex()
|
||||
|
||||
val bedtime: Flow<BedtimeSettings> = settings.bedtime
|
||||
|
||||
/**
|
||||
* A change made on the bedtime screen: stored, then applied at once. The
|
||||
* rule is rewritten when a field it carries changed. When the schedule
|
||||
* or the rule changed, the mode is switched to what it says now, so
|
||||
* turning bedtime on at 23:30 for a 23:00 bedtime starts it straight away
|
||||
* (and a rewritten rule cannot be left off if the platform reset its
|
||||
* state); a reminder edit leaves a mode switched by hand alone.
|
||||
*/
|
||||
suspend fun update(transform: (BedtimeSettings) -> BedtimeSettings) = lock.withLock {
|
||||
val previous = settings.currentBedtime()
|
||||
val updated = transform(previous)
|
||||
settings.setBedtime(updated)
|
||||
val refreshRule = previous.ruleFields() != updated.ruleFields()
|
||||
apply(
|
||||
updated,
|
||||
refreshRule = refreshRule,
|
||||
force = refreshRule || previous.scheduleFields() != updated.scheduleFields(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything that can invalidate the slot or the rule: app launch (a
|
||||
* force stop clears the slot), a reboot, an app update, a clock or zone
|
||||
* change, and Do Not Disturb access changing (a restore has
|
||||
* [resyncAfterRestore]). The mode
|
||||
* is only switched when the schedule crossed a boundary since Clockula
|
||||
* last switched it, so a mode the user turned on or off by hand stays
|
||||
* that way until the next bedtime or wake-up. [refreshRule] also rewrites
|
||||
* the rule and re-asserts the mode's state.
|
||||
*/
|
||||
suspend fun resync(refreshRule: Boolean = false) = lock.withLock {
|
||||
apply(settings.currentBedtime(), refreshRule = refreshRule, force = refreshRule)
|
||||
}
|
||||
|
||||
/**
|
||||
* The slot arriving: the mode follows any boundary crossed, a due
|
||||
* reminder is posted, and the next event is armed. A reminder fire alone
|
||||
* crosses no boundary, so it leaves the mode as it is.
|
||||
*/
|
||||
suspend fun onSlotFired() = lock.withLock {
|
||||
val current = settings.currentBedtime()
|
||||
val now = wallClock.now()
|
||||
val zone = zones.current()
|
||||
apply(current, refreshRule = false, force = false, now = now, zone = zone)
|
||||
BedtimeSchedule.dueReminder(current, now, zone)?.let { window ->
|
||||
// What is actually left: a late fire must not promise the full lead.
|
||||
val minutesLeft = ceil((window.start - now) / 1.minutes).toInt().coerceAtLeast(1)
|
||||
notifier.showReminder(window.start, minutesLeft)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether Clockula may change Do Not Disturb, for the screen's onboarding row. */
|
||||
fun hasAccess(): Boolean = mode.hasAccess()
|
||||
|
||||
val supportsEffects: Boolean get() = mode.supportsEffects
|
||||
|
||||
fun ruleId(): String? = mode.ruleId()
|
||||
|
||||
/**
|
||||
* One pass, over one reading of the clock: the mode's state and the next
|
||||
* slot must agree about which side of a boundary "now" is on.
|
||||
*/
|
||||
private suspend fun apply(
|
||||
current: BedtimeSettings,
|
||||
refreshRule: Boolean,
|
||||
force: Boolean,
|
||||
now: Instant = wallClock.now(),
|
||||
zone: ZoneId = zones.current(),
|
||||
) {
|
||||
if (!current.enabled) {
|
||||
// Already off and cleaned up: nothing to do, which keeps every
|
||||
// launch and boot of a user who never turned bedtime on cheap.
|
||||
if (!force && !refreshRule && !settings.bedtimeAppliedActive() &&
|
||||
(!mode.hasAccess() || mode.ruleId() == null)
|
||||
) {
|
||||
scheduler.cancel()
|
||||
return
|
||||
}
|
||||
notifier.cancelReminder()
|
||||
// Removing the rule ends the mode if it is on. Without access there
|
||||
// is nothing Clockula may remove; revoking access in Settings
|
||||
// already removed the rule.
|
||||
val removed = !mode.hasAccess() || mode.uninstall()
|
||||
if (removed) {
|
||||
settings.setBedtimeAppliedActive(false)
|
||||
scheduler.cancel()
|
||||
} else {
|
||||
// Refused: keep the slot as the retry, so the mode cannot stay
|
||||
// on until some unrelated launch or reboot happens along.
|
||||
scheduler.schedule(now + RETRY_AFTER, urgent = false)
|
||||
}
|
||||
return
|
||||
}
|
||||
val window = BedtimeSchedule.activeWindow(current, now, zone)
|
||||
val next = BedtimeSchedule.nextEvent(current, now, zone)
|
||||
// Anything below may throw or be refused; the slot is armed whatever
|
||||
// happens, and pulled in to a retry when the mode is not where it
|
||||
// should be.
|
||||
var retry = true
|
||||
try {
|
||||
// A reminder that no longer points at a coming bedtime (it started,
|
||||
// or the schedule changed under it) is stale.
|
||||
if (BedtimeSchedule.dueReminder(current, now, zone) == null) notifier.cancelReminder()
|
||||
retry = mode.hasAccess() && !syncMode(current, active = window != null, refreshRule, force)
|
||||
} finally {
|
||||
arm(next, now, retry = retry, urgent = window != null && next == window.end)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the rule and its state in line; false when something is still
|
||||
* owed. Only what the system accepted is recorded as applied. A refused
|
||||
* rewrite or state change is remembered as owed and forced by every pass
|
||||
* until it lands, so a refusal neither hides the next real boundary nor
|
||||
* rests on the one caller that asked.
|
||||
*/
|
||||
private suspend fun syncMode(current: BedtimeSettings, active: Boolean, refreshRule: Boolean, force: Boolean): Boolean {
|
||||
val stale = refreshRule || settings.bedtimeRuleStale()
|
||||
val outcome = mode.install(current, refresh = stale)
|
||||
if (stale) settings.setBedtimeRuleStale(outcome == InstallOutcome.FAILED)
|
||||
// A rule that was just (re)created or rewritten may start off
|
||||
// (Android 10 to 14 clear the condition on an update), so it is
|
||||
// switched regardless of what was last applied.
|
||||
val reset = outcome == InstallOutcome.CREATED || outcome == InstallOutcome.UPDATED
|
||||
val owed = settings.bedtimeStateOwed()
|
||||
var stateOk = true
|
||||
if (force || reset || owed || active != settings.bedtimeAppliedActive()) {
|
||||
stateOk = mode.setActive(active)
|
||||
if (stateOk) settings.setBedtimeAppliedActive(active)
|
||||
if (stateOk != !owed) settings.setBedtimeStateOwed(!stateOk)
|
||||
}
|
||||
return outcome != InstallOutcome.FAILED && stateOk
|
||||
}
|
||||
|
||||
/**
|
||||
* The next event, or sooner as a retry. Only the wake-up boundary, which
|
||||
* ends the mode, is [urgent]: one allow-while-idle fire a night, so the
|
||||
* mode cannot linger into the morning behind a doze deferral.
|
||||
*/
|
||||
private fun arm(next: Instant?, now: Instant, retry: Boolean, urgent: Boolean) {
|
||||
val retryAt = now + RETRY_AFTER
|
||||
when {
|
||||
retry && (next == null || retryAt < next) -> scheduler.schedule(retryAt, urgent = false)
|
||||
next != null -> scheduler.schedule(next, urgent = urgent)
|
||||
else -> scheduler.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a backup restore: the same field comparison an edit makes, against
|
||||
* what was stored before, so restoring the same schedule (or one that only
|
||||
* differs in its reminder) leaves a hand-switched mode alone.
|
||||
*/
|
||||
suspend fun resyncAfterRestore(previous: BedtimeSettings) = lock.withLock {
|
||||
val current = settings.currentBedtime()
|
||||
val refreshRule = previous.ruleFields() != current.ruleFields()
|
||||
apply(current, refreshRule = refreshRule, force = refreshRule || previous.scheduleFields() != current.scheduleFields())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val RETRY_AFTER = 15.minutes
|
||||
}
|
||||
|
||||
/** What the system rule is built from; the reminder is not part of it. */
|
||||
private fun BedtimeSettings.ruleFields() = listOf(enabled, bedtime, wakeTime, effects)
|
||||
|
||||
/** What decides whether the mode should be on right now. */
|
||||
private fun BedtimeSettings.scheduleFields() = listOf(enabled, bedtime, wakeTime, days)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime
|
||||
|
||||
/**
|
||||
* The bedtime slot's broadcast. Internal and unexported, like the alarm's and
|
||||
* the timer's: only the PendingIntent this app created can deliver it.
|
||||
*/
|
||||
object BedtimeIntents {
|
||||
const val ACTION_SLOT: String = "de.jeanlucmakiola.clockula.bedtime.action.SLOT"
|
||||
|
||||
/** Opens the bedtime screen, from the reminder notification. */
|
||||
const val ACTION_SHOW_BEDTIME: String = "de.jeanlucmakiola.clockula.action.SHOW_BEDTIME"
|
||||
|
||||
/**
|
||||
* The extras Settings puts on the rule's configuration activity
|
||||
* (`MainActivity`) when the user taps "Clockula" on the mode's page:
|
||||
* `NotificationManager.EXTRA_AUTOMATIC_RULE_ID` and
|
||||
* `ConditionProviderService.EXTRA_RULE_ID`. Settings sends no action with
|
||||
* them, only the explicit component, so their presence is the signal.
|
||||
* Spelled out so the shell's pure routing table needs no Android import.
|
||||
*/
|
||||
val ZEN_RULE_EXTRAS: List<String> = listOf(
|
||||
"android.app.extra.AUTOMATIC_RULE_ID",
|
||||
"android.service.notification.extra.RULE_ID",
|
||||
)
|
||||
|
||||
/**
|
||||
* Apart from every alarm and timer request code, although the explicit
|
||||
* receiver and the action already keep these PendingIntents distinct.
|
||||
*/
|
||||
const val REQUEST_SLOT: Int = 40
|
||||
|
||||
const val REQUEST_SHOW: Int = 41
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The AlarmManager seam: one slot of its own, on its own receiver and request
|
||||
* code, so nothing bedtime does can replace or cancel an alarm's or a timer's
|
||||
* registration.
|
||||
*/
|
||||
interface BedtimeScheduler {
|
||||
/**
|
||||
* [urgent] is the one event a night that may draw on the platform's
|
||||
* allow-while-idle ration: the wake-up that ends the mode.
|
||||
*/
|
||||
fun schedule(at: Instant, urgent: Boolean)
|
||||
|
||||
fun cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* The Do Not Disturb seam: Clockula's one automatic zen rule (a "mode" on
|
||||
* Android 15 and later). Every call is a no-op without Do Not Disturb access,
|
||||
* and none of them throws: a failure here must never stop the slot from being
|
||||
* rescheduled.
|
||||
*/
|
||||
interface BedtimeMode {
|
||||
/** Whether the user granted Do Not Disturb access ("Special app access"). */
|
||||
fun hasAccess(): Boolean
|
||||
|
||||
/** Whether the mode can carry screen effects (Android 15 and later). */
|
||||
val supportsEffects: Boolean
|
||||
|
||||
/**
|
||||
* Creates the rule when it is missing. [refresh] also rewrites an existing
|
||||
* one from [settings]; otherwise an existing rule is left exactly as it is.
|
||||
*/
|
||||
fun install(settings: BedtimeSettings, refresh: Boolean): InstallOutcome
|
||||
|
||||
/** Turns the mode on or off; false when there is no rule or the system refused. */
|
||||
fun setActive(active: Boolean): Boolean
|
||||
|
||||
/** Removes the rule, which also ends it if it is on; false when the system refused. */
|
||||
fun uninstall(): Boolean
|
||||
|
||||
/** The rule's id, for a deep link into the system's settings; null when there is none. */
|
||||
fun ruleId(): String?
|
||||
}
|
||||
|
||||
/** What [BedtimeMode.install] did. */
|
||||
enum class InstallOutcome { UNCHANGED, CREATED, UPDATED, FAILED }
|
||||
|
||||
/** The reminder notification. */
|
||||
interface BedtimeNotifier {
|
||||
fun showReminder(start: Instant, minutesBefore: Int)
|
||||
|
||||
fun cancelReminder()
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime.android
|
||||
|
||||
import android.app.AutomaticZenRule
|
||||
import android.app.NotificationManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.service.notification.Condition
|
||||
import android.service.notification.ZenDeviceEffects
|
||||
import android.service.notification.ZenPolicy
|
||||
import androidx.annotation.RequiresApi
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.clockula.MainActivity
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeMode
|
||||
import de.jeanlucmakiola.clockula.bedtime.InstallOutcome
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import java.time.LocalTime
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Clockula's one automatic zen rule. On Android 15 and later it is a mode in
|
||||
* Settings › Modes, typed as a time schedule, under Clockula's name and icon,
|
||||
* carrying the screen effects the user picked. On Android 10 to 14 it is a
|
||||
* plain Do Not Disturb schedule rule.
|
||||
*
|
||||
* Digital Wellbeing's own Bedtime mode is off limits: the platform refuses
|
||||
* `TYPE_BEDTIME` from any package but the system's wellbeing app, and no app
|
||||
* can read its schedule. This is a separate mode next to it.
|
||||
*
|
||||
* **Alarms always get through.** The rule's [ZenPolicy] allows alarms (and
|
||||
* media, for a sleep sound) and leaves everything else to the device's own Do
|
||||
* Not Disturb defaults. A rule needs an owner service or a configuration
|
||||
* activity; [MainActivity] is the latter, and it opens the bedtime screen when
|
||||
* Settings opens it for the rule. It is already exported, so no new
|
||||
* component is exposed for this.
|
||||
*
|
||||
* Nothing is stored about the rule. [NotificationManager.getAutomaticZenRules]
|
||||
* lists only this app's own rules, and the condition id tells Clockula's apart,
|
||||
* so a reinstall, a cleared app or a restore onto another device finds or
|
||||
* recreates it without a stale id. Every call is guarded: a revoked access or
|
||||
* an OEM quirk costs the mode, never the caller.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidBedtimeMode @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) : BedtimeMode {
|
||||
|
||||
private val manager: NotificationManager = context.getSystemService(NotificationManager::class.java)
|
||||
|
||||
private val conditionId: Uri = Uri.Builder()
|
||||
.scheme(Condition.SCHEME)
|
||||
.authority(context.packageName)
|
||||
.appendPath("bedtime")
|
||||
.build()
|
||||
|
||||
override val supportsEffects: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM
|
||||
|
||||
override fun hasAccess(): Boolean = guarded(false) { manager.isNotificationPolicyAccessGranted }
|
||||
|
||||
override fun install(settings: BedtimeSettings, refresh: Boolean): InstallOutcome =
|
||||
guarded(InstallOutcome.FAILED) {
|
||||
val ours = ownRules()
|
||||
// Only ever one. A second (two passes racing on another process,
|
||||
// say) is removed rather than left to fight the first.
|
||||
ours.keys.drop(1).forEach(manager::removeAutomaticZenRule)
|
||||
val existing = ours.entries.firstOrNull()
|
||||
if (existing == null) {
|
||||
manager.addAutomaticZenRule(rule(settings, enabled = true))
|
||||
return@guarded InstallOutcome.CREATED
|
||||
}
|
||||
if (refresh) {
|
||||
// Keeps what the user set for the rule in Settings: its on/off
|
||||
// switch (a change here must not re-enable a rule they
|
||||
// disabled) and who may interrupt, which Android 10 to 14
|
||||
// would otherwise overwrite. Android 15 keeps both itself.
|
||||
manager.updateAutomaticZenRule(
|
||||
existing.key,
|
||||
rule(settings, enabled = existing.value.isEnabled, kept = existing.value.zenPolicy),
|
||||
)
|
||||
return@guarded InstallOutcome.UPDATED
|
||||
}
|
||||
InstallOutcome.UNCHANGED
|
||||
}
|
||||
|
||||
override fun setActive(active: Boolean): Boolean =
|
||||
guarded(false) {
|
||||
val id = ownRules().keys.firstOrNull() ?: return@guarded false
|
||||
val state = if (active) Condition.STATE_TRUE else Condition.STATE_FALSE
|
||||
val summary = context.getString(R.string.bedtime_mode_name)
|
||||
val condition = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
// A schedule's own transition, so Android 15 keeps a user's manual
|
||||
// override of the mode until the schedule's next one.
|
||||
Condition(conditionId, summary, state, Condition.SOURCE_SCHEDULE)
|
||||
} else {
|
||||
Condition(conditionId, summary, state)
|
||||
}
|
||||
manager.setAutomaticZenRuleState(id, condition)
|
||||
true
|
||||
}
|
||||
|
||||
override fun uninstall(): Boolean =
|
||||
guarded(false) {
|
||||
ownRules().keys.forEach(manager::removeAutomaticZenRule)
|
||||
true
|
||||
}
|
||||
|
||||
override fun ruleId(): String? = guarded(null) { ownRules().keys.firstOrNull() }
|
||||
|
||||
private fun ownRules(): Map<String, AutomaticZenRule> =
|
||||
manager.automaticZenRules.filterValues { it.conditionId == conditionId }
|
||||
|
||||
/**
|
||||
* [kept] is the existing rule's policy, reused as long as it lets alarms
|
||||
* through: a user's edit in Settings survives, and an edit that blocked
|
||||
* alarms is the one thing replaced.
|
||||
*/
|
||||
private fun rule(settings: BedtimeSettings, enabled: Boolean, kept: ZenPolicy? = null): AutomaticZenRule {
|
||||
val name = context.getString(R.string.bedtime_mode_name)
|
||||
val configuration = ComponentName(context, MainActivity::class.java)
|
||||
val policy = kept?.takeIf { it.priorityCategoryAlarms == ZenPolicy.STATE_ALLOW }
|
||||
?: ZenPolicy.Builder()
|
||||
.allowAlarms(true)
|
||||
.allowMedia(true)
|
||||
.build()
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
modeRule(name, configuration, policy, settings, enabled)
|
||||
} else {
|
||||
AutomaticZenRule(
|
||||
name,
|
||||
null,
|
||||
configuration,
|
||||
conditionId,
|
||||
policy,
|
||||
NotificationManager.INTERRUPTION_FILTER_PRIORITY,
|
||||
enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||
private fun modeRule(
|
||||
name: String,
|
||||
configuration: ComponentName,
|
||||
policy: ZenPolicy,
|
||||
settings: BedtimeSettings,
|
||||
enabled: Boolean,
|
||||
): AutomaticZenRule = AutomaticZenRule.Builder(name, conditionId)
|
||||
.setType(AutomaticZenRule.TYPE_SCHEDULE_TIME)
|
||||
.setConfigurationActivity(configuration)
|
||||
.setZenPolicy(policy)
|
||||
.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_PRIORITY)
|
||||
.setDeviceEffects(
|
||||
ZenDeviceEffects.Builder()
|
||||
.setShouldDisplayGrayscale(settings.effects.grayscale)
|
||||
.setShouldDimWallpaper(settings.effects.dimWallpaper)
|
||||
.setShouldUseNightMode(settings.effects.darkTheme)
|
||||
.setShouldSuppressAmbientDisplay(settings.effects.hideAlwaysOnDisplay)
|
||||
.build(),
|
||||
)
|
||||
.setTriggerDescription(
|
||||
context.getString(
|
||||
R.string.bedtime_mode_trigger,
|
||||
formatTime(settings.bedtime),
|
||||
formatTime(settings.wakeTime),
|
||||
),
|
||||
)
|
||||
.setIconResId(R.drawable.ic_bedtime)
|
||||
.setManualInvocationAllowed(true)
|
||||
.setEnabled(enabled)
|
||||
.build()
|
||||
|
||||
private fun formatTime(time: TimeOfDay): String = formatBedtimeTime(context, LocalTime.of(time.hour, time.minute))
|
||||
|
||||
private inline fun <T> guarded(fallback: T, block: () -> T): T = try {
|
||||
block()
|
||||
} catch (_: RuntimeException) {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime.android
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.clockula.MainActivity
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeNotifier
|
||||
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.toJavaInstant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The "bedtime soon" reminder, on a channel of its own so it can be silenced
|
||||
* without touching anything an alarm or timer posts. Denied the notification
|
||||
* permission, the user loses the reminder and nothing else.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidBedtimeNotifier @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val zones: ZoneProvider,
|
||||
) : BedtimeNotifier {
|
||||
|
||||
private val notifications = NotificationManagerCompat.from(context)
|
||||
|
||||
override fun showReminder(start: Instant, minutesBefore: Int) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
val time = formatBedtimeTime(context, start.toJavaInstant().atZone(zones.current()).toLocalTime())
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_bedtime)
|
||||
.setContentTitle(
|
||||
context.resources.getQuantityString(R.plurals.bedtime_reminder_title, minutesBefore, minutesBefore),
|
||||
)
|
||||
.setContentText(context.getString(R.string.bedtime_reminder_text, time))
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(showPendingIntent())
|
||||
.build()
|
||||
runCatching { notifications.notify(NOTIFICATION_ID, notification) }
|
||||
}
|
||||
|
||||
override fun cancelReminder() = notifications.cancel(NOTIFICATION_ID)
|
||||
|
||||
private fun showPendingIntent(): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
BedtimeIntents.REQUEST_SHOW,
|
||||
Intent(context, MainActivity::class.java)
|
||||
.setAction(BedtimeIntents.ACTION_SHOW_BEDTIME)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID: String = "bedtime_reminder"
|
||||
|
||||
/** Next to the other fixed ids (1_001..1_004), below the snoozed alarms' 2_000 + id. */
|
||||
private const val NOTIFICATION_ID: Int = 1_005
|
||||
|
||||
/** Idempotent; called from the app class on every start, like the other channels. */
|
||||
fun createChannel(context: Context) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.channel_bedtime_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = context.getString(R.string.channel_bedtime_description)
|
||||
}
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime.android
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeScheduler
|
||||
import de.jeanlucmakiola.clockula.bedtime.receiver.BedtimeReceiver
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* One wall-clock slot for the schedule's next event. Never `setAlarmClock`:
|
||||
* that slot is the status bar's "next alarm", and bedtime is not an alarm.
|
||||
*
|
||||
* Only the wake-up that ends the mode is allow-while-idle, so Do Not Disturb
|
||||
* cannot linger hours into the morning behind a doze deferral. That is one
|
||||
* such fire a night, well within the per-app ration the timer's expiry slot
|
||||
* also uses (alarms themselves use `setAlarmClock`, which is not rationed).
|
||||
* Bedtime's start, the reminder and retries are exact but doze-deferrable:
|
||||
* a late start in a dozing, untouched phone costs nothing. It is its own
|
||||
* PendingIntent (its own receiver, action and request code), so arming or
|
||||
* cancelling it can never touch an alarm's or a timer's registration.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidBedtimeScheduler @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) : BedtimeScheduler {
|
||||
|
||||
private val manager: AlarmManager = context.getSystemService(AlarmManager::class.java)
|
||||
|
||||
override fun schedule(at: Instant, urgent: Boolean) {
|
||||
val millis = at.toEpochMilliseconds()
|
||||
val pendingIntent = slotPendingIntent()
|
||||
if (urgent) {
|
||||
val exact = runCatching {
|
||||
manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent)
|
||||
}.isSuccess
|
||||
if (!exact) runCatching { manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) }
|
||||
return
|
||||
}
|
||||
val exact = runCatching { manager.setExact(AlarmManager.RTC_WAKEUP, millis, pendingIntent) }.isSuccess
|
||||
// Guarded too: bedtime losing its slot is survivable, a crash in the
|
||||
// receiver's process is not.
|
||||
// A bounded window rather than `set`, whose slack grows with the
|
||||
// distance to the trigger and could end an 8-hour night hours late.
|
||||
if (!exact) {
|
||||
runCatching { manager.setWindow(AlarmManager.RTC_WAKEUP, millis, FALLBACK_WINDOW_MILLIS, pendingIntent) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() = manager.cancel(slotPendingIntent())
|
||||
|
||||
private companion object {
|
||||
/** The platform's own minimum window from Android 12 on. */
|
||||
const val FALLBACK_WINDOW_MILLIS = 10 * 60_000L
|
||||
}
|
||||
|
||||
private fun slotPendingIntent(): PendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
BedtimeIntents.REQUEST_SLOT,
|
||||
Intent(context, BedtimeReceiver::class.java).setAction(BedtimeIntents.ACTION_SLOT),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime.android
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.DateFormat
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
/**
|
||||
* The same two steps `ui/common/AlarmTimeFormatter.kt` takes — the device's
|
||||
* 12/24-hour choice, then the locale's short form — for the two bedtime
|
||||
* strings the system shows outside the app: the mode's trigger line and the
|
||||
* reminder notification.
|
||||
*/
|
||||
internal fun formatBedtimeTime(context: Context, time: LocalTime): String {
|
||||
val formatter = if (DateFormat.is24HourFormat(context)) {
|
||||
DateTimeFormatter.ofPattern("HH:mm")
|
||||
} else {
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
}
|
||||
return formatter.format(time)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime.di
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeMode
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeNotifier
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeScheduler
|
||||
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeMode
|
||||
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeNotifier
|
||||
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeScheduler
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** The three seams `BedtimeEngine` sees, which keep it JVM-testable. */
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class BedtimeModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindBedtimeScheduler(impl: AndroidBedtimeScheduler): BedtimeScheduler
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindBedtimeMode(impl: AndroidBedtimeMode): BedtimeMode
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindBedtimeNotifier(impl: AndroidBedtimeNotifier): BedtimeNotifier
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime.receiver
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.clockula.alarm.receiver.HiltBroadcastReceiver
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
|
||||
import de.jeanlucmakiola.floret.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The bedtime slot arriving. Do Not Disturb access changing is not heard
|
||||
* here: the platform delivers `ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED`
|
||||
* to runtime-registered receivers only, so the bedtime screen re-reads access
|
||||
* on every `ON_RESUME` instead, and any later pass picks a grant up.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class BedtimeReceiver : HiltBroadcastReceiver() {
|
||||
|
||||
@Inject
|
||||
lateinit var engine: BedtimeEngine
|
||||
|
||||
@Inject
|
||||
@ApplicationScope
|
||||
lateinit var scope: CoroutineScope
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
super.onReceive(context, intent)
|
||||
|
||||
val action = intent.action ?: return
|
||||
val pendingResult = goAsync()
|
||||
scope.launch {
|
||||
try {
|
||||
// Guarded: a platform refusal here must not crash the process,
|
||||
// which is the process an alarm may be about to ring in.
|
||||
if (action == BedtimeIntents.ACTION_SLOT) runCatching { engine.onSlotFired() }
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.data.alarms
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
@@ -24,6 +25,10 @@ abstract class AlarmDao {
|
||||
@Insert
|
||||
abstract suspend fun insert(alarm: AlarmEntity): Long
|
||||
|
||||
/** Inserts with the row's own id; -1 when that id is taken, which writes nothing. */
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract suspend fun insertIfAbsent(alarm: AlarmEntity): Long
|
||||
|
||||
@Update
|
||||
abstract suspend fun update(alarm: AlarmEntity)
|
||||
|
||||
@@ -36,6 +41,24 @@ abstract class AlarmDao {
|
||||
@Query("UPDATE alarms SET skip_next_occurrence = :skip, updated_at = :updatedAt WHERE id = :id")
|
||||
abstract suspend fun setSkipNextOccurrence(id: Long, skip: Boolean, updatedAt: Long)
|
||||
|
||||
/** Clears the date only while it is still [expected], so a newly picked one survives. */
|
||||
@Query(
|
||||
"UPDATE alarms SET scheduled_date = NULL, updated_at = :updatedAt " +
|
||||
"WHERE id = :id AND scheduled_date = :expected",
|
||||
)
|
||||
abstract suspend fun clearScheduledDate(id: Long, expected: Long, updatedAt: Long)
|
||||
|
||||
/**
|
||||
* Clears the pause only while it is still [expectedStart]..[expectedEnd],
|
||||
* in either order: the mapper reads a reversed row as the ordered range.
|
||||
*/
|
||||
@Query(
|
||||
"UPDATE alarms SET pause_start = NULL, pause_end = NULL, updated_at = :updatedAt " +
|
||||
"WHERE id = :id AND MIN(pause_start, pause_end) = :expectedStart " +
|
||||
"AND MAX(pause_start, pause_end) = :expectedEnd",
|
||||
)
|
||||
abstract suspend fun clearPause(id: Long, expectedStart: Long, expectedEnd: Long, updatedAt: Long)
|
||||
|
||||
/**
|
||||
* Reads the row, applies [transform] and writes the result back inside one
|
||||
* transaction. A missing row, or a null result, writes nothing. [transform]
|
||||
|
||||
@@ -7,7 +7,8 @@ import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* The `alarms` row. Primitives only — every enum is stored as [Enum.name], every
|
||||
* instant as epoch millis and the repeat set as a 7-bit mask, so the whole
|
||||
* instant as epoch millis, every local date as an epoch day and the repeat set
|
||||
* as a 7-bit mask, so the whole
|
||||
* entity/domain translation lives in [AlarmMapper] where it can be tested.
|
||||
*/
|
||||
@Entity(tableName = "alarms", indices = [Index("enabled")])
|
||||
@@ -28,4 +29,9 @@ data class AlarmEntity(
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
||||
@ColumnInfo(name = "delete_after_use", defaultValue = "0") val deleteAfterUse: Boolean = false,
|
||||
/** v4: `LocalDate.toEpochDay()` — a calendar date, so no zone is baked in. */
|
||||
@ColumnInfo(name = "scheduled_date") val scheduledDate: Long? = null,
|
||||
/** v4: both ends inclusive epoch days; one without the other reads as no pause. */
|
||||
@ColumnInfo(name = "pause_start") val pauseStart: Long? = null,
|
||||
@ColumnInfo(name = "pause_end") val pauseEnd: Long? = null,
|
||||
)
|
||||
|
||||
@@ -1,34 +1,45 @@
|
||||
package de.jeanlucmakiola.clockula.data.alarms
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
import de.jeanlucmakiola.clockula.domain.AlarmDates
|
||||
import de.jeanlucmakiola.clockula.domain.AlarmDraft
|
||||
import de.jeanlucmakiola.clockula.domain.ClockLimits
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.PauseRange
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.floret.prefs.toEnum
|
||||
import java.time.LocalDate
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Entity ↔ domain. Reads are **forgiving**: a corrupt row degrades, never throws. */
|
||||
object AlarmMapper {
|
||||
fun toDomain(entity: AlarmEntity): Alarm = Alarm(
|
||||
id = entity.id,
|
||||
time = TimeOfDay.clamped(entity.hour, entity.minute),
|
||||
label = entity.label,
|
||||
enabled = entity.enabled,
|
||||
repeatDays = RepeatDays.fromMask(entity.repeatDays),
|
||||
skipNextOccurrence = entity.skipNextOccurrence,
|
||||
ringtoneUri = entity.ringtoneUri?.takeIf { it.isNotBlank() },
|
||||
vibrate = entity.vibrate,
|
||||
snoozeMinutes = entity.snoozeMinutes,
|
||||
snoozeLimit = entity.snoozeLimit,
|
||||
volumeRampSeconds = entity.volumeRampSeconds,
|
||||
// An absent challenge is an *override that isn't set*, so it stays null;
|
||||
// a stored name the app no longer knows degrades to NONE.
|
||||
dismissChallenge = entity.dismissChallenge?.toEnum(DismissChallenge.NONE),
|
||||
createdAt = Instant.fromEpochMilliseconds(entity.createdAt),
|
||||
updatedAt = Instant.fromEpochMilliseconds(entity.updatedAt),
|
||||
deleteAfterUse = entity.deleteAfterUse,
|
||||
)
|
||||
fun toDomain(entity: AlarmEntity): Alarm {
|
||||
val repeatDays = RepeatDays.fromMask(entity.repeatDays)
|
||||
return Alarm(
|
||||
id = entity.id,
|
||||
time = TimeOfDay.clamped(entity.hour, entity.minute),
|
||||
label = entity.label,
|
||||
enabled = entity.enabled,
|
||||
repeatDays = repeatDays,
|
||||
skipNextOccurrence = entity.skipNextOccurrence,
|
||||
ringtoneUri = entity.ringtoneUri?.takeIf { it.isNotBlank() },
|
||||
vibrate = entity.vibrate,
|
||||
snoozeMinutes = entity.snoozeMinutes?.coerceIn(ClockLimits.SNOOZE_MINUTES),
|
||||
snoozeLimit = entity.snoozeLimit?.coerceIn(ClockLimits.SNOOZE_LIMIT),
|
||||
volumeRampSeconds = entity.volumeRampSeconds?.coerceIn(ClockLimits.VOLUME_RAMP_SECONDS),
|
||||
// An absent challenge is an *override that isn't set*, so it stays null;
|
||||
// a stored name the app no longer knows degrades to NONE.
|
||||
dismissChallenge = entity.dismissChallenge?.toEnum(DismissChallenge.NONE),
|
||||
createdAt = Instant.fromEpochMilliseconds(entity.createdAt),
|
||||
updatedAt = Instant.fromEpochMilliseconds(entity.updatedAt),
|
||||
deleteAfterUse = entity.deleteAfterUse,
|
||||
// A date belongs to a one-shot alarm and a pause to a repeating one; the
|
||||
// other combination is never written, so reading it as absent is safe.
|
||||
scheduledDate = epochDay(entity.scheduledDate).takeUnless { repeatDays.isRepeating },
|
||||
pause = pauseOf(entity.pauseStart, entity.pauseEnd).takeIf { repeatDays.isRepeating },
|
||||
)
|
||||
}
|
||||
|
||||
fun toDomain(entities: List<AlarmEntity>): List<Alarm> = entities.map(::toDomain)
|
||||
|
||||
@@ -49,6 +60,9 @@ object AlarmMapper {
|
||||
createdAt = alarm.createdAt.toEpochMilliseconds(),
|
||||
updatedAt = alarm.updatedAt.toEpochMilliseconds(),
|
||||
deleteAfterUse = alarm.deleteAfterUse,
|
||||
scheduledDate = alarm.scheduledDate?.toEpochDay(),
|
||||
pauseStart = alarm.pause?.start?.toEpochDay(),
|
||||
pauseEnd = alarm.pause?.end?.toEpochDay(),
|
||||
)
|
||||
|
||||
/** id = 0, createdAt = updatedAt = [now]. */
|
||||
@@ -70,4 +84,17 @@ object AlarmMapper {
|
||||
updatedAt = now.toEpochMilliseconds(),
|
||||
deleteAfterUse = draft.deleteAfterUse,
|
||||
)
|
||||
|
||||
/** Out of [AlarmDates]' range is corrupt, and reads as no date at all. */
|
||||
private fun epochDay(value: Long?): LocalDate? {
|
||||
if (value == null) return null
|
||||
if (value !in AlarmDates.MIN.toEpochDay()..AlarmDates.MAX.toEpochDay()) return null
|
||||
return LocalDate.ofEpochDay(value)
|
||||
}
|
||||
|
||||
private fun pauseOf(start: Long?, end: Long?): PauseRange? {
|
||||
val from = epochDay(start) ?: return null
|
||||
val until = epochDay(end) ?: return null
|
||||
return PauseRange.ordered(from, until)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package de.jeanlucmakiola.clockula.data.alarms
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
import de.jeanlucmakiola.clockula.domain.AlarmDraft
|
||||
import de.jeanlucmakiola.clockula.domain.PauseRange
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.time.LocalDate
|
||||
|
||||
interface AlarmRepository {
|
||||
fun alarms(): Flow<List<Alarm>>
|
||||
@@ -12,9 +14,25 @@ interface AlarmRepository {
|
||||
suspend fun create(draft: AlarmDraft): Long
|
||||
suspend fun update(alarm: Alarm)
|
||||
suspend fun delete(id: Long)
|
||||
|
||||
/**
|
||||
* Puts a deleted alarm back under its own id, timestamps and all — the
|
||||
* Undo after a delete. `alarms.id` is `AUTOINCREMENT`, so no new alarm can
|
||||
* have claimed it; should the id be taken all the same, nothing is written
|
||||
* and the answer is false, rather than a crash or an overwrite.
|
||||
*/
|
||||
suspend fun restore(alarm: Alarm): Boolean
|
||||
suspend fun setEnabled(id: Long, enabled: Boolean)
|
||||
suspend fun setSkipNextOccurrence(id: Long, skip: Boolean)
|
||||
|
||||
/**
|
||||
* Drops a spent date or pause, but only while the stored one is still
|
||||
* [expected]: a column-targeted write, so no other field is rewritten
|
||||
* and a value the editor has just picked is never cleared.
|
||||
*/
|
||||
suspend fun clearScheduledDate(id: Long, expected: LocalDate)
|
||||
suspend fun clearPause(id: Long, expected: PauseRange)
|
||||
|
||||
/**
|
||||
* Transactional read-modify-write. Stamps `updated_at` from the wall clock
|
||||
* and keeps the id, so a concurrent `setEnabled` from the engine cannot be
|
||||
|
||||
@@ -2,9 +2,11 @@ package de.jeanlucmakiola.clockula.data.alarms
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
import de.jeanlucmakiola.clockula.domain.AlarmDraft
|
||||
import de.jeanlucmakiola.clockula.domain.PauseRange
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.time.LocalDate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -36,12 +38,21 @@ class AlarmRepositoryImpl @Inject constructor(
|
||||
|
||||
override suspend fun delete(id: Long) = dao.deleteById(id)
|
||||
|
||||
override suspend fun restore(alarm: Alarm): Boolean =
|
||||
dao.insertIfAbsent(AlarmMapper.toEntity(alarm)) != -1L
|
||||
|
||||
override suspend fun setEnabled(id: Long, enabled: Boolean) =
|
||||
dao.setEnabled(id, enabled, wallClock.now().toEpochMilliseconds())
|
||||
|
||||
override suspend fun setSkipNextOccurrence(id: Long, skip: Boolean) =
|
||||
dao.setSkipNextOccurrence(id, skip, wallClock.now().toEpochMilliseconds())
|
||||
|
||||
override suspend fun clearScheduledDate(id: Long, expected: LocalDate) =
|
||||
dao.clearScheduledDate(id, expected.toEpochDay(), wallClock.now().toEpochMilliseconds())
|
||||
|
||||
override suspend fun clearPause(id: Long, expected: PauseRange) =
|
||||
dao.clearPause(id, expected.start.toEpochDay(), expected.end.toEpochDay(), wallClock.now().toEpochMilliseconds())
|
||||
|
||||
override suspend fun edit(id: Long, transform: (Alarm) -> Alarm) {
|
||||
val updatedAt = wallClock.now().toEpochMilliseconds()
|
||||
dao.updateWithin(id) { entity ->
|
||||
|
||||
@@ -1,26 +1,50 @@
|
||||
package de.jeanlucmakiola.clockula.data.backup
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.AlarmDates
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.ClockLimits
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.PauseRange
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.Zones
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupAlarm
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupContent
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeLimits
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.toEnum
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeParseException
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** The result of [BackupCodec.decode]: either a validated [BackupContent], or why not. */
|
||||
/**
|
||||
* Why a restore failed, in words a user can act on. The codec's own four, plus
|
||||
* the two the caller meets around it: the file would not open, or the import
|
||||
* itself failed after the file was valid.
|
||||
*/
|
||||
enum class BackupProblem { NOT_A_BACKUP, NEWER_VERSION, INVALID_DATA, TOO_LARGE, UNREADABLE, NOT_APPLIED }
|
||||
|
||||
/**
|
||||
* The result of [BackupCodec.decode]: either a validated [BackupContent], or
|
||||
* why not — [problem] for the user, [reason] the specific detail, in English,
|
||||
* for tests and logs.
|
||||
*/
|
||||
sealed interface BackupResult {
|
||||
data class Success(val content: BackupContent) : BackupResult
|
||||
data class Failure(val reason: String) : BackupResult
|
||||
data class Failure(val problem: BackupProblem, val reason: String) : BackupResult
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,6 +56,13 @@ sealed interface BackupResult {
|
||||
*/
|
||||
object BackupCodec {
|
||||
|
||||
private const val MAX_DOCUMENT_CHARS = 8 * 1024 * 1024
|
||||
private const val MAX_ALARMS = 10_000
|
||||
private const val MAX_TIMERS = 10_000
|
||||
private const val MAX_WORLD_CLOCKS = 1_000
|
||||
private const val MAX_LABEL_CHARS = 64 * 1_024
|
||||
private const val MAX_URI_CHARS = 64 * 1_024
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
@@ -41,12 +72,15 @@ object BackupCodec {
|
||||
json.encodeToString(BackupDocument.serializer(), content.toDocument())
|
||||
|
||||
fun decode(text: String): BackupResult {
|
||||
if (text.length > MAX_DOCUMENT_CHARS) {
|
||||
return BackupResult.Failure(BackupProblem.TOO_LARGE, "backup exceeds the 8 MiB limit")
|
||||
}
|
||||
val document = try {
|
||||
json.decodeFromString(BackupDocument.serializer(), text)
|
||||
} catch (e: SerializationException) {
|
||||
return BackupResult.Failure("not a valid backup file: ${e.message}")
|
||||
return BackupResult.Failure(BackupProblem.NOT_A_BACKUP, "not a valid backup file: ${e.message}")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return BackupResult.Failure("not a valid backup file: ${e.message}")
|
||||
return BackupResult.Failure(BackupProblem.NOT_A_BACKUP, "not a valid backup file: ${e.message}")
|
||||
}
|
||||
return document.toContent()
|
||||
}
|
||||
@@ -77,6 +111,9 @@ object BackupCodec {
|
||||
volumeRampSeconds = volumeRampSeconds,
|
||||
dismissChallenge = dismissChallenge?.name,
|
||||
deleteAfterUse = deleteAfterUse,
|
||||
scheduledDate = scheduledDate?.toString(),
|
||||
pauseStart = pause?.start?.toString(),
|
||||
pauseEnd = pause?.end?.toString(),
|
||||
)
|
||||
|
||||
private fun BackupTimer.toDto() = TimerDto(
|
||||
@@ -102,48 +139,125 @@ object BackupCodec {
|
||||
volumeRampSeconds = defaults.volumeRampSeconds,
|
||||
alarmRingtoneUri = defaults.alarmRingtoneUri,
|
||||
timerRingtoneUri = defaults.timerRingtoneUri,
|
||||
timerVibrate = defaults.timerVibrate,
|
||||
timerVolumeRampSeconds = defaults.timerVolumeRampSeconds,
|
||||
dismissChallenge = defaults.dismissChallenge.name,
|
||||
homeZoneId = defaults.homeZoneId,
|
||||
alarmSilenceAfterMinutes = defaults.alarmSilenceAfterMinutes,
|
||||
timerSilenceAfterMinutes = defaults.timerSilenceAfterMinutes,
|
||||
volumeButtons = defaults.volumeButtons.name,
|
||||
automaticLocalClock = defaults.automaticLocalClock,
|
||||
worldClockSeconds = worldClockSeconds,
|
||||
timerPresets = timerPresets.map { it.inWholeMilliseconds },
|
||||
bedtime = bedtime?.toDto(),
|
||||
screenSaverStyle = screenSaver.style.name,
|
||||
screenSaverNightMode = screenSaver.nightMode,
|
||||
quickTileDurationMillis = quickTileDuration.inWholeMilliseconds,
|
||||
)
|
||||
|
||||
private fun BedtimeSettings.toDto() = BedtimeDto(
|
||||
enabled = enabled,
|
||||
bedtimeMinutes = bedtime.minutesOfDay,
|
||||
wakeMinutes = wakeTime.minutesOfDay,
|
||||
days = days.mask,
|
||||
reminderMinutes = reminderMinutes,
|
||||
grayscale = effects.grayscale,
|
||||
dimWallpaper = effects.dimWallpaper,
|
||||
darkTheme = effects.darkTheme,
|
||||
hideAlwaysOnDisplay = effects.hideAlwaysOnDisplay,
|
||||
)
|
||||
|
||||
/**
|
||||
* Forgiving, like the other settings: an out-of-range time or reminder is
|
||||
* clamped rather than failing the import.
|
||||
*/
|
||||
private fun BedtimeDto.toBedtime() = BedtimeSettings(
|
||||
enabled = enabled,
|
||||
bedtime = minutesToTime(bedtimeMinutes),
|
||||
wakeTime = minutesToTime(wakeMinutes),
|
||||
days = BedtimeLimits.nights(days),
|
||||
reminderMinutes = reminderMinutes.coerceIn(BedtimeLimits.REMINDER_MINUTES),
|
||||
effects = BedtimeEffects(
|
||||
grayscale = grayscale,
|
||||
dimWallpaper = dimWallpaper,
|
||||
darkTheme = darkTheme,
|
||||
hideAlwaysOnDisplay = hideAlwaysOnDisplay,
|
||||
),
|
||||
)
|
||||
|
||||
private fun minutesToTime(minutes: Int): TimeOfDay {
|
||||
val clamped = minutes.coerceIn(0, 24 * 60 - 1)
|
||||
return TimeOfDay(clamped / 60, clamped % 60)
|
||||
}
|
||||
|
||||
// --- decode + validate ---
|
||||
|
||||
private fun BackupDocument.toContent(): BackupResult {
|
||||
if (format != BACKUP_FORMAT) return BackupResult.Failure("unknown format: '$format'")
|
||||
if (version != BACKUP_VERSION) return BackupResult.Failure("unsupported backup version: $version")
|
||||
if (format != BACKUP_FORMAT) {
|
||||
return BackupResult.Failure(BackupProblem.NOT_A_BACKUP, "unknown format: '$format'")
|
||||
}
|
||||
if (version !in BACKUP_MIN_VERSION..BACKUP_VERSION) {
|
||||
val problem = if (version > BACKUP_VERSION) BackupProblem.NEWER_VERSION else BackupProblem.INVALID_DATA
|
||||
return BackupResult.Failure(problem, "unsupported backup version: $version")
|
||||
}
|
||||
if (alarms.size > MAX_ALARMS || timers.size > MAX_TIMERS || worldClocks.size > MAX_WORLD_CLOCKS) {
|
||||
return BackupResult.Failure(BackupProblem.TOO_LARGE, "backup contains too many records")
|
||||
}
|
||||
|
||||
val alarms = mutableListOf<BackupAlarm>()
|
||||
for ((index, dto) in this.alarms.withIndex()) {
|
||||
if (dto.hour !in 0..23 || dto.minute !in 0..59) {
|
||||
return BackupResult.Failure(
|
||||
BackupProblem.INVALID_DATA,
|
||||
"alarms[$index]: hour/minute out of range (${dto.hour}:${dto.minute})",
|
||||
)
|
||||
}
|
||||
if (dto.label.length > MAX_LABEL_CHARS || (dto.ringtoneUri?.length ?: 0) > MAX_URI_CHARS) {
|
||||
return BackupResult.Failure(
|
||||
BackupProblem.TOO_LARGE,
|
||||
"alarms[$index]: label or ringtone URI is too long",
|
||||
)
|
||||
}
|
||||
val repeatDays = RepeatDays.fromMask(dto.repeatDays)
|
||||
val dates = dto.dates(repeatDays.isRepeating)
|
||||
?: return BackupResult.Failure(
|
||||
BackupProblem.INVALID_DATA,
|
||||
"alarms[$index]: scheduled_date or pause is not a valid date range",
|
||||
)
|
||||
alarms += BackupAlarm(
|
||||
time = TimeOfDay(dto.hour, dto.minute),
|
||||
label = dto.label,
|
||||
enabled = dto.enabled,
|
||||
repeatDays = RepeatDays.fromMask(dto.repeatDays),
|
||||
repeatDays = repeatDays,
|
||||
skipNextOccurrence = dto.skipNextOccurrence,
|
||||
ringtoneUri = dto.ringtoneUri?.takeIf { it.isNotBlank() },
|
||||
vibrate = dto.vibrate,
|
||||
snoozeMinutes = dto.snoozeMinutes,
|
||||
snoozeLimit = dto.snoozeLimit,
|
||||
volumeRampSeconds = dto.volumeRampSeconds,
|
||||
// The same clamps the defaults pass through: an alarm's own
|
||||
// override is no freer than the setting it overrides.
|
||||
snoozeMinutes = dto.snoozeMinutes?.coerceIn(ClockLimits.SNOOZE_MINUTES),
|
||||
snoozeLimit = dto.snoozeLimit?.coerceIn(ClockLimits.SNOOZE_LIMIT),
|
||||
volumeRampSeconds = dto.volumeRampSeconds?.coerceIn(ClockLimits.VOLUME_RAMP_SECONDS),
|
||||
dismissChallenge = dto.dismissChallenge?.toEnum(DismissChallenge.NONE),
|
||||
deleteAfterUse = dto.deleteAfterUse,
|
||||
scheduledDate = dates.scheduledDate,
|
||||
pause = dates.pause,
|
||||
)
|
||||
}
|
||||
|
||||
val timers = mutableListOf<BackupTimer>()
|
||||
for ((index, dto) in this.timers.withIndex()) {
|
||||
if (dto.durationMillis < 0) {
|
||||
return BackupResult.Failure("timers[$index]: negative duration")
|
||||
return BackupResult.Failure(BackupProblem.INVALID_DATA, "timers[$index]: negative duration")
|
||||
}
|
||||
if (dto.label.length > MAX_LABEL_CHARS || (dto.ringtoneUri?.length ?: 0) > MAX_URI_CHARS) {
|
||||
return BackupResult.Failure(
|
||||
BackupProblem.TOO_LARGE,
|
||||
"timers[$index]: label or ringtone URI is too long",
|
||||
)
|
||||
}
|
||||
timers += BackupTimer(
|
||||
label = dto.label,
|
||||
duration = dto.durationMillis.milliseconds,
|
||||
duration = dto.durationMillis.milliseconds.coerceIn(TimerPresets.MIN, TimerPresets.MAX),
|
||||
ringtoneUri = dto.ringtoneUri?.takeIf { it.isNotBlank() },
|
||||
sortOrder = dto.sortOrder,
|
||||
deleteAfterUse = dto.deleteAfterUse,
|
||||
@@ -153,7 +267,13 @@ object BackupCodec {
|
||||
val worldClocks = mutableListOf<BackupWorldClock>()
|
||||
for ((index, dto) in this.worldClocks.withIndex()) {
|
||||
if (!Zones.isValid(dto.zoneId)) {
|
||||
return BackupResult.Failure("worldClocks[$index]: not an IANA zone id: '${dto.zoneId}'")
|
||||
return BackupResult.Failure(
|
||||
BackupProblem.INVALID_DATA,
|
||||
"worldClocks[$index]: not an IANA zone id: '${dto.zoneId}'",
|
||||
)
|
||||
}
|
||||
if ((dto.label?.length ?: 0) > MAX_LABEL_CHARS) {
|
||||
return BackupResult.Failure(BackupProblem.TOO_LARGE, "worldClocks[$index]: label is too long")
|
||||
}
|
||||
worldClocks += BackupWorldClock(
|
||||
zoneId = dto.zoneId,
|
||||
@@ -163,6 +283,12 @@ object BackupCodec {
|
||||
}
|
||||
|
||||
val settingsDto = settings
|
||||
if ((settingsDto.alarmRingtoneUri?.length ?: 0) > MAX_URI_CHARS ||
|
||||
(settingsDto.timerRingtoneUri?.length ?: 0) > MAX_URI_CHARS ||
|
||||
(settingsDto.timerPresets?.size ?: 0) > 1_000
|
||||
) {
|
||||
return BackupResult.Failure(BackupProblem.TOO_LARGE, "settings contain oversized values")
|
||||
}
|
||||
val settingsContent = BackupSettings(
|
||||
themeMode = settingsDto.themeMode.toEnum(ThemeMode.SYSTEM),
|
||||
dynamicColor = settingsDto.dynamicColor,
|
||||
@@ -173,10 +299,24 @@ object BackupCodec {
|
||||
volumeRampSeconds = settingsDto.volumeRampSeconds,
|
||||
alarmRingtoneUri = settingsDto.alarmRingtoneUri?.takeIf { it.isNotBlank() },
|
||||
timerRingtoneUri = settingsDto.timerRingtoneUri?.takeIf { it.isNotBlank() },
|
||||
timerVibrate = settingsDto.timerVibrate ?: settingsDto.vibrate,
|
||||
timerVolumeRampSeconds = settingsDto.timerVolumeRampSeconds,
|
||||
dismissChallenge = settingsDto.dismissChallenge.toEnum(DismissChallenge.NONE),
|
||||
homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise),
|
||||
// Clamped on apply, by the preference, like every other default.
|
||||
alarmSilenceAfterMinutes = settingsDto.alarmSilenceAfterMinutes,
|
||||
timerSilenceAfterMinutes = settingsDto.timerSilenceAfterMinutes,
|
||||
volumeButtons = settingsDto.volumeButtons.toEnum(VolumeButtonAction.CONTROL_VOLUME),
|
||||
automaticLocalClock = settingsDto.automaticLocalClock,
|
||||
),
|
||||
timerPresets = settingsDto.timerPresets.map { it.milliseconds },
|
||||
timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN,
|
||||
worldClockSeconds = settingsDto.worldClockSeconds,
|
||||
screenSaver = ScreenSaverSettings(
|
||||
style = settingsDto.screenSaverStyle.toEnum(ScreenSaverStyle.DIGITAL),
|
||||
nightMode = settingsDto.screenSaverNightMode,
|
||||
),
|
||||
quickTileDuration = parseQuickTimerDuration(settingsDto.quickTileDurationMillis),
|
||||
bedtime = settingsDto.bedtime?.toBedtime(),
|
||||
)
|
||||
|
||||
return BackupResult.Success(
|
||||
@@ -189,4 +329,36 @@ object BackupCodec {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private class AlarmDatesContent(val scheduledDate: LocalDate?, val pause: PauseRange?)
|
||||
|
||||
/**
|
||||
* Null when a date is present but malformed, out of [AlarmDates]' range, or
|
||||
* a pause has only one end or ends before it starts. Strict, like the
|
||||
* alarm's own hour and minute: a date is the alarm's meaning, not a
|
||||
* preference that can degrade to a default. Only the fields the alarm can
|
||||
* carry are read — a date belongs to a one-shot, a pause to a repeating
|
||||
* alarm, as the mapper reads them — so a leftover on the other kind is
|
||||
* dropped unread rather than able to reject the file.
|
||||
*/
|
||||
private fun AlarmDto.dates(repeating: Boolean): AlarmDatesContent? {
|
||||
if (!repeating) {
|
||||
val scheduled = scheduledDate?.let { parseDate(it) ?: return null }
|
||||
return AlarmDatesContent(scheduled, null)
|
||||
}
|
||||
val startText = pauseStart
|
||||
val endText = pauseEnd
|
||||
if (startText == null && endText == null) return AlarmDatesContent(null, null)
|
||||
if (startText == null || endText == null) return null
|
||||
val start = parseDate(startText) ?: return null
|
||||
val end = parseDate(endText) ?: return null
|
||||
if (end.isBefore(start)) return null
|
||||
return AlarmDatesContent(null, PauseRange(start, end))
|
||||
}
|
||||
|
||||
private fun parseDate(text: String): LocalDate? = try {
|
||||
LocalDate.parse(text).takeIf(AlarmDates::isValid)
|
||||
} catch (e: DateTimeParseException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package de.jeanlucmakiola.clockula.data.backup
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** `docs/BACKUP.md`'s top-level constants. */
|
||||
const val BACKUP_FORMAT: String = "clockula-backup"
|
||||
const val BACKUP_VERSION: Int = 1
|
||||
const val BACKUP_VERSION: Int = 2
|
||||
|
||||
/** The oldest version this build still reads. v1 is v2 without the alarm dates. */
|
||||
const val BACKUP_MIN_VERSION: Int = 1
|
||||
|
||||
/** The wire shape of `clockula-backup-v1.json`. See `docs/BACKUP.md`. */
|
||||
@Serializable
|
||||
@@ -35,6 +40,10 @@ data class AlarmDto(
|
||||
@SerialName("volume_ramp_seconds") val volumeRampSeconds: Int? = null,
|
||||
@SerialName("dismiss_challenge") val dismissChallenge: String? = null,
|
||||
@SerialName("delete_after_use") val deleteAfterUse: Boolean = false,
|
||||
/** v2: ISO-8601 local dates (`2026-10-28`). */
|
||||
@SerialName("scheduled_date") val scheduledDate: String? = null,
|
||||
@SerialName("pause_start") val pauseStart: String? = null,
|
||||
@SerialName("pause_end") val pauseEnd: String? = null,
|
||||
)
|
||||
|
||||
/** Config only — never state, the running anchors, or `ends_at_wall_clock`. */
|
||||
@@ -69,7 +78,56 @@ data class SettingsDto(
|
||||
@SerialName("default_volume_ramp_seconds") val volumeRampSeconds: Int = 15,
|
||||
@SerialName("default_alarm_ringtone_uri") val alarmRingtoneUri: String? = null,
|
||||
@SerialName("default_timer_ringtone_uri") val timerRingtoneUri: String? = null,
|
||||
/**
|
||||
* Absent in a file from before timers had their own vibration; such a file
|
||||
* restores [vibrate] here, which is what its timers vibrated by.
|
||||
*/
|
||||
@SerialName("default_timer_vibrate") val timerVibrate: Boolean? = null,
|
||||
@SerialName("default_timer_volume_ramp_seconds") val timerVolumeRampSeconds: Int = 0,
|
||||
@SerialName("default_dismiss_challenge") val dismissChallenge: String = "NONE",
|
||||
@SerialName("home_zone_id") val homeZoneId: String? = null,
|
||||
@SerialName("timer_presets") val timerPresets: List<Long> = emptyList(),
|
||||
@SerialName("volume_buttons_while_ringing") val volumeButtons: String = "CONTROL_VOLUME",
|
||||
/**
|
||||
* Minutes, or 0 for "Never". Absent in a file from before the setting
|
||||
* existed, which is v1's fixed ten minutes.
|
||||
*/
|
||||
@SerialName("default_alarm_silence_after_minutes") val alarmSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
|
||||
@SerialName("default_timer_silence_after_minutes") val timerSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
|
||||
/** Absent in files from before #24, which keeps the default: on. */
|
||||
@SerialName("automatic_local_clock") val automaticLocalClock: Boolean = true,
|
||||
/** Absent in files from before it existed, which keeps the default: off. */
|
||||
@SerialName("world_clock_show_seconds") val worldClockSeconds: Boolean = false,
|
||||
/**
|
||||
* Absent is not empty: an empty list is a user who removed every preset,
|
||||
* while a file without the field (an older or hand-written one) keeps the
|
||||
* built-in ones.
|
||||
*/
|
||||
@SerialName("timer_presets") val timerPresets: List<Long>? = null,
|
||||
/** Absent from files written before issue #1; restoring one keeps the current bedtime settings. */
|
||||
val bedtime: BedtimeDto? = null,
|
||||
/** Absent in files from before the screen saver; they restore its defaults. */
|
||||
@SerialName("screen_saver_style") val screenSaverStyle: String = "DIGITAL",
|
||||
@SerialName("screen_saver_night_mode") val screenSaverNightMode: Boolean = false,
|
||||
/** Absent (a file from before #26) or out of range keeps the tile's default. */
|
||||
@SerialName("quick_tile_duration_millis") val quickTileDurationMillis: Long? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The bedtime schedule (issue #1). Times are minutes since midnight, like the
|
||||
* `BedtimePrefs` keys; `days` is the same 7-bit mask an alarm's `repeat_days` is.
|
||||
*/
|
||||
@Serializable
|
||||
data class BedtimeDto(
|
||||
val enabled: Boolean = DEFAULT_BEDTIME.enabled,
|
||||
@SerialName("bedtime_minutes") val bedtimeMinutes: Int = DEFAULT_BEDTIME.bedtime.minutesOfDay,
|
||||
@SerialName("wake_minutes") val wakeMinutes: Int = DEFAULT_BEDTIME.wakeTime.minutesOfDay,
|
||||
val days: Int = DEFAULT_BEDTIME.days.mask,
|
||||
@SerialName("reminder_minutes") val reminderMinutes: Int = DEFAULT_BEDTIME.reminderMinutes,
|
||||
val grayscale: Boolean = DEFAULT_BEDTIME.effects.grayscale,
|
||||
@SerialName("dim_wallpaper") val dimWallpaper: Boolean = DEFAULT_BEDTIME.effects.dimWallpaper,
|
||||
@SerialName("dark_theme") val darkTheme: Boolean = DEFAULT_BEDTIME.effects.darkTheme,
|
||||
@SerialName("hide_always_on_display") val hideAlwaysOnDisplay: Boolean = DEFAULT_BEDTIME.effects.hideAlwaysOnDisplay,
|
||||
)
|
||||
|
||||
/** A missing field restores what a fresh install has. */
|
||||
private val DEFAULT_BEDTIME = BedtimeSettings()
|
||||
|
||||
@@ -70,6 +70,8 @@ private fun Alarm.toBackup() = BackupAlarm(
|
||||
volumeRampSeconds = volumeRampSeconds,
|
||||
dismissChallenge = dismissChallenge,
|
||||
deleteAfterUse = deleteAfterUse,
|
||||
scheduledDate = scheduledDate,
|
||||
pause = pause,
|
||||
)
|
||||
|
||||
private fun Timer.toBackup() = BackupTimer(
|
||||
@@ -100,6 +102,9 @@ private fun BackupAlarm.toEntity(nowMillis: Long) = AlarmEntity(
|
||||
createdAt = nowMillis,
|
||||
updatedAt = nowMillis,
|
||||
deleteAfterUse = deleteAfterUse,
|
||||
scheduledDate = scheduledDate?.toEpochDay(),
|
||||
pauseStart = pause?.start?.toEpochDay(),
|
||||
pauseEnd = pause?.end?.toEpochDay(),
|
||||
)
|
||||
|
||||
/** Imported timers always land IDLE with the full configured duration (`docs/BACKUP.md`). */
|
||||
|
||||
@@ -15,7 +15,7 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockDao
|
||||
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity
|
||||
|
||||
/**
|
||||
* Clockula's own storage. Version 3; the schema is exported to `app/schemas/`
|
||||
* Clockula's own storage. Version 4; the schema is exported to `app/schemas/`
|
||||
* and committed, so every later migration is reviewable and testable.
|
||||
*
|
||||
* No `TypeConverters` — every column is a primitive and the whole translation
|
||||
@@ -30,7 +30,7 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity
|
||||
WorldClockEntity::class,
|
||||
LapEntity::class,
|
||||
],
|
||||
version = 3,
|
||||
version = 4,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class ClockulaDatabase : RoomDatabase() {
|
||||
|
||||
@@ -44,5 +44,16 @@ object Migrations {
|
||||
}
|
||||
}
|
||||
|
||||
val ALL: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3)
|
||||
/** v3 → v4: `scheduled_date`, `pause_start` and `pause_end` on `alarms` (#21). */
|
||||
val MIGRATION_3_4: Migration = object : Migration(3, 4) {
|
||||
override fun migrate(connection: SQLiteConnection) {
|
||||
// Nullable with no default, as the entity declares them: every alarm
|
||||
// written before v4 reads as unscheduled and unpaused.
|
||||
connection.execSQL("ALTER TABLE `alarms` ADD COLUMN `scheduled_date` INTEGER")
|
||||
connection.execSQL("ALTER TABLE `alarms` ADD COLUMN `pause_start` INTEGER")
|
||||
connection.execSQL("ALTER TABLE `alarms` ADD COLUMN `pause_end` INTEGER")
|
||||
}
|
||||
}
|
||||
|
||||
val ALL: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,16 @@ import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.clockula.data.time.AndroidBootIdProvider
|
||||
import de.jeanlucmakiola.clockula.data.time.AndroidClockChanges
|
||||
import de.jeanlucmakiola.clockula.data.time.AndroidHourFormat
|
||||
import de.jeanlucmakiola.clockula.data.time.RealTicker
|
||||
import de.jeanlucmakiola.clockula.data.time.SystemElapsedRealtimeClock
|
||||
import de.jeanlucmakiola.clockula.data.time.SystemWallClock
|
||||
import de.jeanlucmakiola.clockula.data.time.SystemZoneProvider
|
||||
import de.jeanlucmakiola.clockula.domain.time.BootIdProvider
|
||||
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
|
||||
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.HourFormat
|
||||
import de.jeanlucmakiola.clockula.domain.time.Ticker
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
|
||||
@@ -39,4 +43,10 @@ abstract class TimeModule {
|
||||
|
||||
@Binds
|
||||
abstract fun bindTicker(impl: RealTicker): Ticker
|
||||
|
||||
@Binds
|
||||
abstract fun bindClockChanges(impl: AndroidClockChanges): ClockChanges
|
||||
|
||||
@Binds
|
||||
abstract fun bindHourFormat(impl: AndroidHourFormat): HourFormat
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package de.jeanlucmakiola.clockula.data.prefs
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeLimits
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.floret.prefs.Pref
|
||||
import de.jeanlucmakiola.floret.prefs.booleanPref
|
||||
import de.jeanlucmakiola.floret.prefs.intPref
|
||||
|
||||
/**
|
||||
* The bedtime schedule's on-disk keys (issue #1). Configuration only: the
|
||||
* id of the system mode Clockula created is not stored at all, because the
|
||||
* platform already lists an app's own rules (`AndroidBedtimeMode`), and a
|
||||
* stored id would go stale after a restore onto another device.
|
||||
*/
|
||||
object BedtimePrefs {
|
||||
|
||||
private val DEFAULT = BedtimeSettings()
|
||||
|
||||
val enabled: Pref<Boolean> = booleanPref("bedtime_enabled", DEFAULT.enabled)
|
||||
|
||||
val bedtime: Pref<TimeOfDay> = intPref("bedtime_start_minutes", DEFAULT.bedtime.minutesOfDay).asTimeOfDay()
|
||||
|
||||
val wakeTime: Pref<TimeOfDay> = intPref("bedtime_wake_minutes", DEFAULT.wakeTime.minutesOfDay).asTimeOfDay()
|
||||
|
||||
/**
|
||||
* The 7-bit mask `RepeatDays` uses; a corrupt value cannot widen the set.
|
||||
* The screen never lets the last night go, so an empty mask (corrupt, or
|
||||
* written around it) reads as every night, as a backup's does.
|
||||
*/
|
||||
val days: Pref<RepeatDays> = intPref("bedtime_days", DEFAULT.days.mask)
|
||||
.map(
|
||||
decode = BedtimeLimits::nights,
|
||||
encode = { it.mask },
|
||||
)
|
||||
|
||||
val reminderMinutes: Pref<Int> = intPref("bedtime_reminder_minutes", DEFAULT.reminderMinutes)
|
||||
.map(
|
||||
decode = { it.coerceIn(BedtimeLimits.REMINDER_MINUTES) },
|
||||
encode = { it.coerceIn(BedtimeLimits.REMINDER_MINUTES) },
|
||||
)
|
||||
|
||||
val grayscale: Pref<Boolean> = booleanPref("bedtime_effect_grayscale", DEFAULT.effects.grayscale)
|
||||
|
||||
val dimWallpaper: Pref<Boolean> = booleanPref("bedtime_effect_dim_wallpaper", DEFAULT.effects.dimWallpaper)
|
||||
|
||||
val darkTheme: Pref<Boolean> = booleanPref("bedtime_effect_dark_theme", DEFAULT.effects.darkTheme)
|
||||
|
||||
val hideAlwaysOnDisplay: Pref<Boolean> =
|
||||
booleanPref("bedtime_effect_hide_aod", DEFAULT.effects.hideAlwaysOnDisplay)
|
||||
|
||||
/**
|
||||
* Whether Clockula last switched the mode on or off: device state, not
|
||||
* configuration, so it is not in the JSON backup. It lets a pass switch
|
||||
* the mode only when the schedule crossed a boundary since, and leave a
|
||||
* mode the user switched by hand alone otherwise.
|
||||
*/
|
||||
val appliedActive: Pref<Boolean> = booleanPref("bedtime_applied_active", false)
|
||||
|
||||
/** A rule rewrite the system refused, still owed; device state like the above. */
|
||||
val ruleStale: Pref<Boolean> = booleanPref("bedtime_rule_stale", false)
|
||||
|
||||
/** A state change the system refused, still owed; device state like the above. */
|
||||
val stateOwed: Pref<Boolean> = booleanPref("bedtime_state_owed", false)
|
||||
|
||||
/** Minutes since midnight on disk, clamped into a day both ways. */
|
||||
private fun Pref<Int>.asTimeOfDay(): Pref<TimeOfDay> = map(
|
||||
decode = { minutes ->
|
||||
val clamped = minutes.coerceIn(0, MINUTES_PER_DAY - 1)
|
||||
TimeOfDay(clamped / 60, clamped % 60)
|
||||
},
|
||||
encode = { it.minutesOfDay },
|
||||
)
|
||||
|
||||
private const val MINUTES_PER_DAY = 24 * 60
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package de.jeanlucmakiola.clockula.data.prefs
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.ClockLimits
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.Zones
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import de.jeanlucmakiola.floret.prefs.Pref
|
||||
import de.jeanlucmakiola.floret.prefs.booleanPref
|
||||
import de.jeanlucmakiola.floret.prefs.enumPref
|
||||
@@ -15,25 +18,80 @@ import de.jeanlucmakiola.floret.prefs.nullableStringPref
|
||||
*/
|
||||
object ClockPrefs {
|
||||
|
||||
val snoozeMinutes: Pref<Int> = intPref("default_snooze_minutes", 10).clampedTo(1..60)
|
||||
val snoozeMinutes: Pref<Int> = intPref("default_snooze_minutes", 10).clampedTo(ClockLimits.SNOOZE_MINUTES)
|
||||
|
||||
val snoozeLimit: Pref<Int> = intPref("default_snooze_limit", 3).clampedTo(0..10)
|
||||
val snoozeLimit: Pref<Int> = intPref("default_snooze_limit", 3).clampedTo(ClockLimits.SNOOZE_LIMIT)
|
||||
|
||||
val vibrate: Pref<Boolean> = booleanPref("default_vibrate", true)
|
||||
|
||||
val volumeRampSeconds: Pref<Int> = intPref("default_volume_ramp_seconds", 15).clampedTo(0..60)
|
||||
val volumeRampSeconds: Pref<Int> = intPref("default_volume_ramp_seconds", 15).clampedTo(ClockLimits.VOLUME_RAMP_SECONDS)
|
||||
|
||||
val alarmRingtoneUri: Pref<String?> = nullableStringPref("default_alarm_ringtone_uri").blankAsAbsent()
|
||||
|
||||
val timerRingtoneUri: Pref<String?> = nullableStringPref("default_timer_ringtone_uri").blankAsAbsent()
|
||||
|
||||
/**
|
||||
* The timers' own vibration (#22). Until it existed a timer vibrated
|
||||
* exactly when [vibrate] said so; `SettingsPrefs.settleTimerVibrate` copies
|
||||
* that over once, so nobody's timers change on update. The default is
|
||||
* [vibrate]'s, for the moment before that lands.
|
||||
*/
|
||||
val timerVibrate: Pref<Boolean> = booleanPref("default_timer_vibrate", true)
|
||||
|
||||
val timerVolumeRampSeconds: Pref<Int> =
|
||||
intPref("default_timer_volume_ramp_seconds", 0).clampedTo(ClockLimits.VOLUME_RAMP_SECONDS)
|
||||
|
||||
val dismissChallenge: Pref<DismissChallenge> =
|
||||
enumPref("default_dismiss_challenge", DismissChallenge.NONE)
|
||||
|
||||
/**
|
||||
* How long a ringing alarm, and a finished timer, keep going before they
|
||||
* stop on their own (#17): minutes, or [SilenceAfter.NEVER], sanitised both
|
||||
* ways through [SilenceAfter.sanitise]. An absent key reads as v1's fixed
|
||||
* ten minutes, so an upgrade changes nothing.
|
||||
*/
|
||||
val alarmSilenceAfterMinutes: Pref<Int> =
|
||||
intPref("default_alarm_silence_after_minutes", SilenceAfter.DEFAULT_MINUTES)
|
||||
.map(decode = SilenceAfter::sanitise, encode = SilenceAfter::sanitise)
|
||||
|
||||
val timerSilenceAfterMinutes: Pref<Int> =
|
||||
intPref("default_timer_silence_after_minutes", SilenceAfter.DEFAULT_MINUTES)
|
||||
.map(decode = SilenceAfter::sanitise, encode = SilenceAfter::sanitise)
|
||||
|
||||
/** No `default_` prefix: no alarm overrides it. */
|
||||
val volumeButtons: Pref<VolumeButtonAction> =
|
||||
enumPref("volume_buttons_while_ringing", VolumeButtonAction.CONTROL_VOLUME)
|
||||
|
||||
/** Normalised through [Zones] both ways, so a zone the device dropped reads as absent. */
|
||||
val homeZoneId: Pref<String?> = nullableStringPref("home_zone_id")
|
||||
.map(decode = Zones::normalise, encode = Zones::normalise)
|
||||
|
||||
/**
|
||||
* The world clock tab's readouts to the second, with a second hand on the
|
||||
* face (#23). Off by default: it is what makes the tab wake every second
|
||||
* rather than every minute. Not part of `ClockDefaults`, which alarms and
|
||||
* timers inherit from, so flipping it re-emits nothing they watch.
|
||||
*/
|
||||
val worldClockSeconds: Pref<Boolean> = booleanPref("world_clock_show_seconds", false)
|
||||
|
||||
/**
|
||||
* Whether a ringing alarm or timer opens its screen over another app
|
||||
* rather than as a heads-up. Off by default, and only in effect while the
|
||||
* "display over other apps" permission is granted. Stored only once that
|
||||
* permission is, and cleared when the settings screen finds it gone, so a
|
||||
* restore that brings the value back without the permission cannot leave
|
||||
* the feature waiting to switch itself on. Not in Clockula's own backup
|
||||
* file, since the permission does not travel with it.
|
||||
*/
|
||||
val ringOverOtherApps: Pref<Boolean> = booleanPref("ring_screen_over_other_apps", false)
|
||||
|
||||
/**
|
||||
* The World clock tab's temporary local-time row while the device is in a
|
||||
* zone whose time differs from home (#24). On by default, as Google Clock's
|
||||
* automatic home clock is.
|
||||
*/
|
||||
val automaticLocalClock: Pref<Boolean> = booleanPref("automatic_local_clock", true)
|
||||
|
||||
private fun Pref<Int>.clampedTo(range: IntRange): Pref<Int> =
|
||||
map(decode = { it.coerceIn(range) }, encode = { it.coerceIn(range) })
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.jeanlucmakiola.clockula.data.prefs
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.floret.prefs.Pref
|
||||
import de.jeanlucmakiola.floret.prefs.booleanPref
|
||||
import de.jeanlucmakiola.floret.prefs.enumPref
|
||||
|
||||
/** The screen saver's on-disk keys, surfaced as `SettingsPrefs.screenSaver`. */
|
||||
object ScreenSaverPrefs {
|
||||
|
||||
val style: Pref<ScreenSaverStyle> = enumPref("screen_saver_style", ScreenSaverStyle.DIGITAL)
|
||||
|
||||
val nightMode: Pref<Boolean> = booleanPref("screen_saver_night_mode", false)
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
package de.jeanlucmakiola.clockula.data.prefs
|
||||
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
import de.jeanlucmakiola.floret.prefs.AppearancePrefs
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.appearance
|
||||
import de.jeanlucmakiola.floret.prefs.set
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -60,19 +69,74 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
dismissChallenge = dismissChallenge,
|
||||
)
|
||||
},
|
||||
store.flow(ClockPrefs.alarmRingtoneUri),
|
||||
store.flow(ClockPrefs.timerRingtoneUri),
|
||||
store.flow(ClockPrefs.homeZoneId),
|
||||
) { partial, alarmRingtoneUri, timerRingtoneUri, homeZoneId ->
|
||||
combine(
|
||||
store.flow(ClockPrefs.alarmSilenceAfterMinutes),
|
||||
store.flow(ClockPrefs.timerSilenceAfterMinutes),
|
||||
store.flow(ClockPrefs.volumeButtons),
|
||||
) { alarmMinutes, timerMinutes, volumeButtons -> Triple(alarmMinutes, timerMinutes, volumeButtons) },
|
||||
combine(
|
||||
store.flow(ClockPrefs.timerRingtoneUri),
|
||||
store.flow(ClockPrefs.timerVibrate),
|
||||
store.flow(ClockPrefs.timerVolumeRampSeconds),
|
||||
) { timerRingtoneUri, timerVibrate, timerVolumeRampSeconds ->
|
||||
Triple(timerRingtoneUri, timerVibrate, timerVolumeRampSeconds)
|
||||
},
|
||||
combine(
|
||||
store.flow(ClockPrefs.alarmRingtoneUri),
|
||||
store.flow(ClockPrefs.homeZoneId),
|
||||
store.flow(ClockPrefs.automaticLocalClock),
|
||||
) { alarmRingtoneUri, homeZoneId, automaticLocalClock ->
|
||||
Triple(alarmRingtoneUri, homeZoneId, automaticLocalClock)
|
||||
},
|
||||
) { partial, (alarmSilenceAfter, timerSilenceAfter, volumeButtons), (timerRingtoneUri, timerVibrate, timerVolumeRampSeconds), (alarmRingtoneUri, homeZoneId, automaticLocalClock) ->
|
||||
partial.copy(
|
||||
alarmRingtoneUri = alarmRingtoneUri,
|
||||
timerRingtoneUri = timerRingtoneUri,
|
||||
timerVibrate = timerVibrate,
|
||||
timerVolumeRampSeconds = timerVolumeRampSeconds,
|
||||
homeZoneId = homeZoneId,
|
||||
alarmSilenceAfterMinutes = alarmSilenceAfter,
|
||||
timerSilenceAfterMinutes = timerSilenceAfter,
|
||||
volumeButtons = volumeButtons,
|
||||
automaticLocalClock = automaticLocalClock,
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
suspend fun currentDefaults(): ClockDefaults = defaults.first()
|
||||
|
||||
/**
|
||||
* Restore every backed-up preference in one DataStore transaction, each
|
||||
* through its own [de.jeanlucmakiola.floret.prefs.Pref], so a restored value
|
||||
* is clamped and encoded exactly as its setter would store it.
|
||||
*/
|
||||
suspend fun applyBackup(backup: BackupSettings) {
|
||||
store.edit { prefs ->
|
||||
prefs[AppearancePrefs.themeMode] = backup.themeMode
|
||||
prefs[AppearancePrefs.dynamicColor] = backup.dynamicColor
|
||||
prefs[ClockPrefs.snoozeMinutes] = backup.defaults.snoozeMinutes
|
||||
prefs[ClockPrefs.snoozeLimit] = backup.defaults.snoozeLimit
|
||||
prefs[ClockPrefs.vibrate] = backup.defaults.vibrate
|
||||
prefs[ClockPrefs.volumeRampSeconds] = backup.defaults.volumeRampSeconds
|
||||
prefs[ClockPrefs.alarmRingtoneUri] = backup.defaults.alarmRingtoneUri
|
||||
prefs[ClockPrefs.timerRingtoneUri] = backup.defaults.timerRingtoneUri
|
||||
prefs[ClockPrefs.timerVibrate] = backup.defaults.timerVibrate
|
||||
prefs[ClockPrefs.timerVolumeRampSeconds] = backup.defaults.timerVolumeRampSeconds
|
||||
prefs[ClockPrefs.dismissChallenge] = backup.defaults.dismissChallenge
|
||||
prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId
|
||||
prefs[ClockPrefs.alarmSilenceAfterMinutes] = backup.defaults.alarmSilenceAfterMinutes
|
||||
prefs[ClockPrefs.timerSilenceAfterMinutes] = backup.defaults.timerSilenceAfterMinutes
|
||||
prefs[ClockPrefs.volumeButtons] = backup.defaults.volumeButtons
|
||||
prefs[ClockPrefs.automaticLocalClock] = backup.defaults.automaticLocalClock
|
||||
prefs[ClockPrefs.worldClockSeconds] = backup.worldClockSeconds
|
||||
prefs[TimerPrefs.presets] = backup.timerPresets
|
||||
// Absent from a file written before bedtime existed: keep it.
|
||||
backup.bedtime?.let { prefs.putBedtime(it) }
|
||||
prefs[ScreenSaverPrefs.style] = backup.screenSaver.style
|
||||
prefs[ScreenSaverPrefs.nightMode] = backup.screenSaver.nightMode
|
||||
prefs[TimerPrefs.quickTileDuration] = backup.quickTileDuration
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setSnoozeMinutes(minutes: Int) = store.set(ClockPrefs.snoozeMinutes, minutes)
|
||||
|
||||
suspend fun setSnoozeLimit(limit: Int) = store.set(ClockPrefs.snoozeLimit, limit)
|
||||
@@ -85,11 +149,54 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
|
||||
suspend fun setTimerRingtoneUri(uri: String?) = store.set(ClockPrefs.timerRingtoneUri, uri)
|
||||
|
||||
suspend fun setTimerVibrate(enabled: Boolean) = store.set(ClockPrefs.timerVibrate, enabled)
|
||||
|
||||
/**
|
||||
* Gives the timers' vibration a value of its own, once (#22): the alarm
|
||||
* default, which is what every timer vibrated by until then. From here on
|
||||
* the two move apart. A no-op once the key exists.
|
||||
*/
|
||||
suspend fun settleTimerVibrate() {
|
||||
store.edit { prefs ->
|
||||
// Raw keys for the reads: the kit reads a Pref only from a store, not
|
||||
// from a transaction's preferences. Both are plain boolean prefs.
|
||||
if (booleanPreferencesKey(ClockPrefs.timerVibrate.name) in prefs) return@edit
|
||||
prefs[ClockPrefs.timerVibrate] =
|
||||
prefs[booleanPreferencesKey(ClockPrefs.vibrate.name)] ?: ClockPrefs.vibrate.default
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setTimerVolumeRampSeconds(seconds: Int) = store.set(ClockPrefs.timerVolumeRampSeconds, seconds)
|
||||
|
||||
suspend fun setDismissChallenge(challenge: DismissChallenge) =
|
||||
store.set(ClockPrefs.dismissChallenge, challenge)
|
||||
|
||||
suspend fun setAlarmSilenceAfterMinutes(minutes: Int) =
|
||||
store.set(ClockPrefs.alarmSilenceAfterMinutes, minutes)
|
||||
|
||||
suspend fun setTimerSilenceAfterMinutes(minutes: Int) =
|
||||
store.set(ClockPrefs.timerSilenceAfterMinutes, minutes)
|
||||
|
||||
suspend fun setVolumeButtons(action: VolumeButtonAction) = store.set(ClockPrefs.volumeButtons, action)
|
||||
|
||||
suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId)
|
||||
|
||||
suspend fun setAutomaticLocalClock(enabled: Boolean) =
|
||||
store.set(ClockPrefs.automaticLocalClock, enabled)
|
||||
|
||||
// --- world clock display (#23) ---
|
||||
|
||||
/** Its own flow, distinct-until-changed, so the clock defaults' watchers never see it move. */
|
||||
val worldClockSeconds: Flow<Boolean> = store.flow(ClockPrefs.worldClockSeconds)
|
||||
|
||||
suspend fun setWorldClockSeconds(enabled: Boolean) = store.set(ClockPrefs.worldClockSeconds, enabled)
|
||||
|
||||
// --- ring screen over other apps ---
|
||||
|
||||
val ringOverOtherApps: Flow<Boolean> = store.flow(ClockPrefs.ringOverOtherApps)
|
||||
|
||||
suspend fun setRingOverOtherApps(enabled: Boolean) = store.set(ClockPrefs.ringOverOtherApps, enabled)
|
||||
|
||||
// --- timer presets (M6) ---
|
||||
|
||||
/**
|
||||
@@ -110,6 +217,91 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
store.update(TimerPrefs.presets) { TimerPresets.minus(it, value) }
|
||||
}
|
||||
|
||||
// --- bedtime (issue #1) ---
|
||||
|
||||
/** Distinct-until-changed, composed from the individual keys like [defaults]. */
|
||||
val bedtime: Flow<BedtimeSettings> = combine(
|
||||
combine(
|
||||
store.flow(BedtimePrefs.enabled),
|
||||
store.flow(BedtimePrefs.bedtime),
|
||||
store.flow(BedtimePrefs.wakeTime),
|
||||
store.flow(BedtimePrefs.days),
|
||||
store.flow(BedtimePrefs.reminderMinutes),
|
||||
) { enabled, bedtime, wakeTime, days, reminderMinutes ->
|
||||
BedtimeSettings(
|
||||
enabled = enabled,
|
||||
bedtime = bedtime,
|
||||
wakeTime = wakeTime,
|
||||
days = days,
|
||||
reminderMinutes = reminderMinutes,
|
||||
)
|
||||
},
|
||||
store.flow(BedtimePrefs.grayscale),
|
||||
store.flow(BedtimePrefs.dimWallpaper),
|
||||
store.flow(BedtimePrefs.darkTheme),
|
||||
store.flow(BedtimePrefs.hideAlwaysOnDisplay),
|
||||
) { partial, grayscale, dimWallpaper, darkTheme, hideAlwaysOnDisplay ->
|
||||
partial.copy(
|
||||
effects = BedtimeEffects(
|
||||
grayscale = grayscale,
|
||||
dimWallpaper = dimWallpaper,
|
||||
darkTheme = darkTheme,
|
||||
hideAlwaysOnDisplay = hideAlwaysOnDisplay,
|
||||
),
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
suspend fun currentBedtime(): BedtimeSettings = bedtime.first()
|
||||
|
||||
/** Every bedtime key in one transaction. `BedtimeEngine` is the only writer. */
|
||||
suspend fun setBedtime(settings: BedtimeSettings) {
|
||||
store.edit { prefs -> prefs.putBedtime(settings) }
|
||||
}
|
||||
|
||||
suspend fun bedtimeAppliedActive(): Boolean = store.get(BedtimePrefs.appliedActive)
|
||||
|
||||
suspend fun setBedtimeAppliedActive(active: Boolean) = store.set(BedtimePrefs.appliedActive, active)
|
||||
|
||||
suspend fun bedtimeRuleStale(): Boolean = store.get(BedtimePrefs.ruleStale)
|
||||
|
||||
suspend fun setBedtimeRuleStale(stale: Boolean) = store.set(BedtimePrefs.ruleStale, stale)
|
||||
|
||||
suspend fun bedtimeStateOwed(): Boolean = store.get(BedtimePrefs.stateOwed)
|
||||
|
||||
suspend fun setBedtimeStateOwed(owed: Boolean) = store.set(BedtimePrefs.stateOwed, owed)
|
||||
|
||||
private fun MutablePreferences.putBedtime(settings: BedtimeSettings) {
|
||||
this[BedtimePrefs.enabled] = settings.enabled
|
||||
this[BedtimePrefs.bedtime] = settings.bedtime
|
||||
this[BedtimePrefs.wakeTime] = settings.wakeTime
|
||||
this[BedtimePrefs.days] = settings.days
|
||||
this[BedtimePrefs.reminderMinutes] = settings.reminderMinutes
|
||||
this[BedtimePrefs.grayscale] = settings.effects.grayscale
|
||||
this[BedtimePrefs.dimWallpaper] = settings.effects.dimWallpaper
|
||||
this[BedtimePrefs.darkTheme] = settings.effects.darkTheme
|
||||
this[BedtimePrefs.hideAlwaysOnDisplay] = settings.effects.hideAlwaysOnDisplay
|
||||
}
|
||||
|
||||
// --- screen saver (issue #25) ---
|
||||
|
||||
/** Built once and composed from its two keys, for the same reasons [defaults] is. */
|
||||
val screenSaver: Flow<ScreenSaverSettings> = combine(
|
||||
store.flow(ScreenSaverPrefs.style),
|
||||
store.flow(ScreenSaverPrefs.nightMode),
|
||||
::ScreenSaverSettings,
|
||||
).distinctUntilChanged()
|
||||
|
||||
suspend fun setScreenSaverStyle(style: ScreenSaverStyle) = store.set(ScreenSaverPrefs.style, style)
|
||||
|
||||
suspend fun setScreenSaverNightMode(enabled: Boolean) = store.set(ScreenSaverPrefs.nightMode, enabled)
|
||||
|
||||
// --- quick-timer tile (#26) ---
|
||||
|
||||
/** What the quick-timer Quick Settings tile starts; bounded as the quick-timer widget's duration is. */
|
||||
val quickTileDuration: Flow<Duration> = store.flow(TimerPrefs.quickTileDuration)
|
||||
|
||||
suspend fun setQuickTileDuration(value: Duration) = store.set(TimerPrefs.quickTileDuration, value)
|
||||
|
||||
// --- first-launch bookkeeping (M4) ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.clockula.data.prefs
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration
|
||||
import de.jeanlucmakiola.floret.prefs.Pref
|
||||
import de.jeanlucmakiola.floret.prefs.nullableLongPref
|
||||
import de.jeanlucmakiola.floret.prefs.nullableStringPref
|
||||
@@ -41,4 +42,22 @@ object TimerPrefs {
|
||||
decode = { millis -> millis?.let(Instant::fromEpochMilliseconds) },
|
||||
encode = { instant -> instant?.toEpochMilliseconds() },
|
||||
)
|
||||
|
||||
/**
|
||||
* The duration the quick-timer Quick Settings tile starts (#26). The
|
||||
* widget's own bounds and default (`QuickTimerWidgetSettings`), sanitised
|
||||
* on write as well as on read, so a stored value is always one the tile
|
||||
* can start. A setting, so it is in the backup.
|
||||
*/
|
||||
val quickTileDuration: Pref<Duration> = nullableLongPref("quick_tile_duration_millis").map(
|
||||
decode = ::parseQuickTimerDuration,
|
||||
encode = { duration -> parseQuickTimerDuration(duration.inWholeMilliseconds).inWholeMilliseconds },
|
||||
)
|
||||
|
||||
/**
|
||||
* The transient timer the quick-timer tile last started, so the tile can
|
||||
* show and control that one. Volatile state, like [ringSoundingSince]: it
|
||||
* names a row that is deleted on reset, and stays out of the backup.
|
||||
*/
|
||||
val quickTileTimerId: Pref<Long?> = nullableLongPref("quick_tile_timer_id")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.jeanlucmakiola.clockula.data.ringtones
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.RingtoneOption
|
||||
import de.jeanlucmakiola.clockula.domain.Ringtones
|
||||
|
||||
/**
|
||||
* The sounds Clockula ships itself, and the one place their stored sentinel
|
||||
* becomes something a `MediaPlayer` or a `ContentResolver` can open. Resolved
|
||||
* against the running package every time, so the stored value never names one.
|
||||
*/
|
||||
object BundledTones {
|
||||
|
||||
/** The bundled sounds as picker rows, in the order the picker shows them. */
|
||||
fun options(context: Context): List<RingtoneOption> = listOf(
|
||||
RingtoneOption(Ringtones.TIMER_EXPIRED_URI, context.getString(R.string.ringtone_timer_expired)),
|
||||
)
|
||||
|
||||
/** The bundled sound's title, or null when [uri] is not one. */
|
||||
fun titleOf(context: Context, uri: String?): String? =
|
||||
options(context).firstOrNull { it.uri == uri }?.title
|
||||
|
||||
/** An `android.resource://` URI for a bundled sound, or null when [uri] is not one. */
|
||||
fun resolve(context: Context, uri: String?): Uri? = when (uri) {
|
||||
Ringtones.TIMER_EXPIRED_URI -> resourceUri(context, R.raw.timer_expire)
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** [resolve], or [uri] parsed as it is. Null only when it does not parse. */
|
||||
fun playable(context: Context, uri: String): Uri? =
|
||||
resolve(context, uri) ?: runCatching { Uri.parse(uri) }.getOrNull()
|
||||
|
||||
private fun resourceUri(context: Context, resId: Int): Uri = Uri.Builder()
|
||||
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
|
||||
.authority(context.packageName)
|
||||
.appendPath(resId.toString())
|
||||
.build()
|
||||
}
|
||||
@@ -21,25 +21,40 @@ internal suspend fun connectMediaBrowser(
|
||||
onSuspended: () -> Unit = {},
|
||||
): MediaBrowser? = suspendCancellableCoroutine { continuation ->
|
||||
var created: MediaBrowser? = null
|
||||
var handedOff = false
|
||||
fun disconnect() {
|
||||
created?.let { runCatching { it.disconnect() } }
|
||||
}
|
||||
val callback = object : MediaBrowser.ConnectionCallback() {
|
||||
override fun onConnected() {
|
||||
if (continuation.isActive) continuation.resume(created)
|
||||
if (continuation.isActive) {
|
||||
handedOff = true
|
||||
continuation.resume(created)
|
||||
} else {
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnectionFailed() {
|
||||
disconnect()
|
||||
if (continuation.isActive) continuation.resume(null)
|
||||
}
|
||||
|
||||
override fun onConnectionSuspended() {
|
||||
if (continuation.isActive) continuation.resume(null) else onSuspended()
|
||||
if (handedOff) {
|
||||
onSuspended()
|
||||
} else {
|
||||
disconnect()
|
||||
if (continuation.isActive) continuation.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
val connecting = runCatching { MediaBrowser(context, component, callback, null) }.getOrNull()
|
||||
created = connecting
|
||||
if (connecting == null || runCatching { connecting.connect() }.isFailure) {
|
||||
connecting?.let { runCatching { it.disconnect() } }
|
||||
disconnect()
|
||||
continuation.resume(null)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
continuation.invokeOnCancellation { runCatching { connecting.disconnect() } }
|
||||
continuation.invokeOnCancellation { disconnect() }
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ package de.jeanlucmakiola.clockula.data.ringtones
|
||||
import de.jeanlucmakiola.clockula.domain.RingtoneOption
|
||||
|
||||
interface RingtoneCatalog {
|
||||
/** The device's alarm sounds, title-sorted. **Empty** rather than throwing. */
|
||||
/**
|
||||
* The sounds bundled with the app, then the device's alarm sounds,
|
||||
* title-sorted. The device part is **empty** rather than throwing.
|
||||
*/
|
||||
suspend fun alarmTones(): List<RingtoneOption>
|
||||
|
||||
/** A display title for [uri], or null when the device cannot name it. */
|
||||
|
||||
+5
-1
@@ -52,9 +52,13 @@ class SystemRingtoneCatalog @Inject constructor(
|
||||
}
|
||||
}
|
||||
}.getOrDefault(emptyList()).sortedBy { it.title.lowercase() }
|
||||
// The app's own sounds first: they are always there, and the device
|
||||
// has no way to list them.
|
||||
.let { device -> BundledTones.options(context) + device }
|
||||
}
|
||||
|
||||
override suspend fun titleOf(uri: String): String? = withContext(io) {
|
||||
BundledTones.titleOf(context, uri)?.let { return@withContext it }
|
||||
// A sound another app plays carries its own title: naming it would
|
||||
// otherwise mean connecting to that app on every editor render.
|
||||
MediaSources.decode(uri)?.let { ref ->
|
||||
@@ -82,7 +86,7 @@ class SystemRingtoneCatalog @Inject constructor(
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return@withContext false
|
||||
val parsed = BundledTones.playable(context, uri) ?: return@withContext false
|
||||
runCatching {
|
||||
context.contentResolver.openInputStream(parsed)?.use { true } ?: false
|
||||
}.getOrDefault(false)
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ class SystemRingtonePreviewer @Inject constructor(
|
||||
stop()
|
||||
return
|
||||
}
|
||||
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return
|
||||
val parsed = BundledTones.playable(context, uri) ?: return
|
||||
// Stop *before* taking the lock: what is sounding now stops now, and an
|
||||
// in-flight `prepare` is told its preview has been superseded.
|
||||
stop()
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.jeanlucmakiola.clockula.data.time
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.text.format.DateFormat
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
|
||||
import de.jeanlucmakiola.clockula.domain.time.HourFormat
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* `TIME_SET` and `TIMEZONE_CHANGED`, through a receiver that is registered
|
||||
* only while somebody collects. Both already have their manifest receiver
|
||||
* (`SystemEventReceiver`) for the alarm engine; this one is for a readout on
|
||||
* screen, which must not wait out its cadence to show either.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidClockChanges @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) : ClockChanges {
|
||||
|
||||
override fun changes(): Flow<Unit> = callbackFlow {
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
trySend(Unit)
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_TIME_CHANGED)
|
||||
addAction(Intent.ACTION_TIMEZONE_CHANGED)
|
||||
}
|
||||
// Protected system broadcasts still arrive at a non-exported receiver,
|
||||
// and nothing else on the device gets to poke the tab.
|
||||
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
awaitClose { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
}
|
||||
|
||||
/** `DateFormat.is24HourFormat`: the user's choice, else the locale's. */
|
||||
class AndroidHourFormat @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : HourFormat {
|
||||
override fun is24Hour(): Boolean = DateFormat.is24HourFormat(context)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package de.jeanlucmakiola.clockula.data.time
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.time.Ticker
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallBoundaries
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
@@ -10,7 +12,7 @@ import javax.inject.Singleton
|
||||
|
||||
/** The [Ticker] seam over `delay`: emit, then wait [ticks]' period, forever. */
|
||||
@Singleton
|
||||
class RealTicker @Inject constructor() : Ticker {
|
||||
class RealTicker @Inject constructor(private val wallClock: WallClock) : Ticker {
|
||||
|
||||
/**
|
||||
* Cold, and per-collector: two readouts on different cadences get two
|
||||
@@ -24,4 +26,18 @@ class RealTicker @Inject constructor() : Ticker {
|
||||
delay(period)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold and per-collector, as [ticks] is. The wait is re-measured against
|
||||
* the wall clock after every emission rather than added up, so a `delay`
|
||||
* that ran a little long does not accumulate. A wall clock set mid-wait is
|
||||
* only caught up after that wait ends; a caller that cannot wait that long
|
||||
* restarts the flow on `ClockChanges`, as the world clock does.
|
||||
*/
|
||||
override fun alignedTicks(unit: Duration): Flow<Unit> = flow {
|
||||
while (true) {
|
||||
emit(Unit)
|
||||
delay(WallBoundaries.untilNext(wallClock.now(), unit))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.jeanlucmakiola.clockula.data.timers
|
||||
|
||||
import de.jeanlucmakiola.clockula.data.prefs.TimerPrefs
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Which transient timer the quick-timer Quick Settings tile last started
|
||||
* (#26) — the tile's counterpart of the quick-timer widget's per-instance
|
||||
* Glance state. One volatile record, so DataStore rather than Room, and out
|
||||
* of the backup for the reason [TimerRingStateStore] gives.
|
||||
*/
|
||||
@Singleton
|
||||
class QuickTileStateStore @Inject constructor(private val store: PrefStore) {
|
||||
|
||||
/** Built once, distinct-until-changed (ARCHITECTURE.md §6). */
|
||||
val timerId: Flow<Long?> = store.flow(TimerPrefs.quickTileTimerId)
|
||||
|
||||
suspend fun currentTimerId(): Long? = store.get(TimerPrefs.quickTileTimerId)
|
||||
|
||||
suspend fun setTimerId(id: Long?) = store.set(TimerPrefs.quickTileTimerId, id)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.data.timers
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
@@ -27,6 +28,10 @@ abstract class TimerDao {
|
||||
@Insert
|
||||
abstract suspend fun insert(timer: TimerEntity): Long
|
||||
|
||||
/** Inserts with the row's own id; -1 when that id is taken, which writes nothing. */
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract suspend fun insertIfAbsent(timer: TimerEntity): Long
|
||||
|
||||
@Update
|
||||
abstract suspend fun update(timer: TimerEntity)
|
||||
|
||||
|
||||
@@ -13,6 +13,13 @@ interface TimerRepository {
|
||||
suspend fun rename(id: Long, label: String)
|
||||
suspend fun delete(id: Long)
|
||||
|
||||
/**
|
||||
* Puts a deleted timer back under its own id, anchors and place in the
|
||||
* list — the Undo after a delete. False, writing nothing, should that id
|
||||
* be taken (`timers.id` is `AUTOINCREMENT`, so no new timer can have it).
|
||||
*/
|
||||
suspend fun restore(timer: Timer): Boolean
|
||||
|
||||
/** Starts an IDLE/EXPIRED timer from its configured duration, resumes a PAUSED one. No-op when RUNNING. */
|
||||
suspend fun start(id: Long)
|
||||
suspend fun pause(id: Long)
|
||||
|
||||
@@ -43,6 +43,9 @@ class TimerRepositoryImpl @Inject constructor(
|
||||
|
||||
override suspend fun delete(id: Long) = dao.deleteById(id)
|
||||
|
||||
override suspend fun restore(timer: Timer): Boolean =
|
||||
dao.insertIfAbsent(TimerMapper.toEntity(timer)) != -1L
|
||||
|
||||
override suspend fun start(id: Long) {
|
||||
val now = elapsedRealtimeClock.elapsedRealtime()
|
||||
val wall = wallClock.now()
|
||||
|
||||
@@ -98,8 +98,8 @@ class ZoneDirectory @Inject constructor(
|
||||
|
||||
/**
|
||||
* One display name per id in [zoneIds], on the same memo. Batched for the
|
||||
* same reason [entriesFor] is: the tab asks five times a second, and a
|
||||
* dispatch and a lock per row would be a hundred and twenty-five of each.
|
||||
* same reason [entriesFor] is: the tab asks on every tick (once a second with
|
||||
* seconds on), and a dispatch and a lock per row would be twenty-five of each.
|
||||
*/
|
||||
suspend fun displayNamesOf(zoneIds: List<String>, at: Instant): Map<String, String?> =
|
||||
withContext(io) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.clockula.domain
|
||||
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** A local time of day. Always valid; use [clamped] at a data boundary. */
|
||||
@@ -69,6 +70,39 @@ value class RepeatDays private constructor(val mask: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A run of whole local dates a repeating alarm stays quiet on, both ends
|
||||
* inclusive. Dates, not instants: "paused from the 26th to the 28th" means the
|
||||
* same three calendar days whatever zone the phone is in when they arrive.
|
||||
*/
|
||||
data class PauseRange(val start: LocalDate, val end: LocalDate) {
|
||||
|
||||
init {
|
||||
require(!end.isBefore(start)) { "pause ends before it starts: $start..$end" }
|
||||
}
|
||||
|
||||
operator fun contains(date: LocalDate): Boolean = !date.isBefore(start) && !date.isAfter(end)
|
||||
|
||||
companion object {
|
||||
/** Never throws: the two ends are put in order. */
|
||||
fun ordered(a: LocalDate, b: LocalDate): PauseRange =
|
||||
if (b.isBefore(a)) PauseRange(b, a) else PauseRange(a, b)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The dates an alarm can be scheduled on or paused for. Anything outside is
|
||||
* corrupt, never a choice: the pickers offer nothing near either end, and
|
||||
* keeping clear of `LocalDate`'s own limits means date arithmetic on a stored
|
||||
* value can never overflow.
|
||||
*/
|
||||
object AlarmDates {
|
||||
val MIN: LocalDate = LocalDate.of(2000, 1, 1)
|
||||
val MAX: LocalDate = LocalDate.of(9999, 12, 31)
|
||||
|
||||
fun isValid(date: LocalDate): Boolean = !date.isBefore(MIN) && !date.isAfter(MAX)
|
||||
}
|
||||
|
||||
/** The optional barrier between a ringing alarm and dismissing it. */
|
||||
enum class DismissChallenge { NONE, MATH, HOLD }
|
||||
|
||||
@@ -93,6 +127,13 @@ data class Alarm(
|
||||
val updatedAt: Instant,
|
||||
/** A transient alarm the `AlarmClock` contract asked for: deleted once its cycle closes (M9 D10). */
|
||||
val deleteAfterUse: Boolean,
|
||||
/**
|
||||
* A one-shot alarm's date: it rings on this local date and not before.
|
||||
* Only ever set on a non-repeating alarm — picking a repeat day clears it.
|
||||
*/
|
||||
val scheduledDate: LocalDate? = null,
|
||||
/** A repeating alarm's holiday: no occurrence on these dates. Only ever set on a repeating alarm. */
|
||||
val pause: PauseRange? = null,
|
||||
)
|
||||
|
||||
/** Everything needed to create an alarm; the repository supplies id and timestamps. */
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
package de.jeanlucmakiola.clockula.domain
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* The app-wide defaults an alarm or timer inherits when it has no override.
|
||||
* Backed by DataStore, edited in M10's settings screen.
|
||||
*
|
||||
* The two silence-after values are app-wide only: no alarm or timer overrides
|
||||
* them (#17). They are stored as [SilenceAfter] minutes; read them as a window
|
||||
* through [alarmSilenceAfter] and [timerSilenceAfter]. [volumeButtons] has no
|
||||
* per-alarm override either (#18); it lives here because it is edited and
|
||||
* backed up with the rest of the alarm settings.
|
||||
*
|
||||
* [vibrate] and [volumeRampSeconds] are the alarms'; timers have their own
|
||||
* [timerVibrate] and [timerVolumeRampSeconds], because a kitchen timer that
|
||||
* should just beep is not an alarm that should wake someone. They start as
|
||||
* timers have always rung: vibrating like the alarms' default, unramped.
|
||||
*/
|
||||
data class ClockDefaults(
|
||||
val snoozeMinutes: Int = 10,
|
||||
@@ -11,6 +26,30 @@ data class ClockDefaults(
|
||||
val volumeRampSeconds: Int = 15,
|
||||
val alarmRingtoneUri: String? = null,
|
||||
val timerRingtoneUri: String? = null,
|
||||
val timerVibrate: Boolean = true,
|
||||
val timerVolumeRampSeconds: Int = 0,
|
||||
val dismissChallenge: DismissChallenge = DismissChallenge.NONE,
|
||||
val homeZoneId: String? = null,
|
||||
)
|
||||
val alarmSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
|
||||
val timerSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
|
||||
val volumeButtons: VolumeButtonAction = VolumeButtonAction.CONTROL_VOLUME,
|
||||
/** The World clock tab's temporary local-time row while travelling (#24). */
|
||||
val automaticLocalClock: Boolean = true,
|
||||
) {
|
||||
/** How long a ringing alarm rings before it is silenced as missed. */
|
||||
val alarmSilenceAfter: Duration get() = SilenceAfter.window(alarmSilenceAfterMinutes)
|
||||
|
||||
/** How long a finished timer sounds before the noise stops. */
|
||||
val timerSilenceAfter: Duration get() = SilenceAfter.window(timerSilenceAfterMinutes)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ranges a user can choose from, for the defaults and for an alarm's own
|
||||
* overrides alike. Anything read back from disk or a backup is clamped into
|
||||
* them, so a value no picker offers never reaches the engine.
|
||||
*/
|
||||
object ClockLimits {
|
||||
val SNOOZE_MINUTES: IntRange = 1..60
|
||||
val SNOOZE_LIMIT: IntRange = 0..10
|
||||
val VOLUME_RAMP_SECONDS: IntRange = 0..60
|
||||
}
|
||||
|
||||
@@ -9,6 +9,17 @@ object Ringtones {
|
||||
|
||||
fun isSilent(uri: String?): Boolean = uri == SILENT_URI
|
||||
|
||||
/**
|
||||
* The "Timer expired" sound Clockula ships in its own resources. Stored under
|
||||
* the private scheme rather than as an `android.resource://` URI, which
|
||||
* carries the package name and so would break between a debug and a release
|
||||
* build, or after a restore into the other one.
|
||||
*/
|
||||
const val TIMER_EXPIRED_URI: String = "clockula://tone/timer_expired"
|
||||
|
||||
/** True for a sound bundled with the app rather than one the device provides. */
|
||||
fun isBundled(uri: String?): Boolean = uri == TIMER_EXPIRED_URI
|
||||
|
||||
/** Trimmed; blank becomes null. [SILENT_URI] passes through unchanged. */
|
||||
fun normalise(uri: String?): String? = uri?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ object Zones {
|
||||
|
||||
/**
|
||||
* Snapshotted once. `ZoneId.getAvailableZoneIds()` builds a fresh ~600-entry
|
||||
* `HashSet` on every call, and the world clock asks per row per 200 ms tick
|
||||
* — twenty-four cities would be a hundred and twenty-five throwaway sets a
|
||||
* `HashSet` on every call, and the world clock asks per row per tick —
|
||||
* with seconds on, twenty-four cities would be twenty-five throwaway sets a
|
||||
* second. The device's tzdata arrives as an APEX module that takes effect
|
||||
* at reboot, so the answer cannot change under a running process.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
|
||||
/** What an Undo puts back: the alarm's row and its ring state, as of the delete. */
|
||||
data class DeletedAlarm(val alarm: Alarm, val state: AlarmRingState)
|
||||
|
||||
/**
|
||||
* What a deleted alarm comes back as. Pure, so the "no stale ring" rule is
|
||||
* decided once, here, and the engine only carries it out.
|
||||
*/
|
||||
object AlarmDeletion {
|
||||
|
||||
/**
|
||||
* The snapshot an Undo restores, or null when there is nothing to restore.
|
||||
*
|
||||
* Everything the user set comes back as it was (label, sound, challenge,
|
||||
* skip-next), and so does the ring state: a snoozed alarm is snoozed again,
|
||||
* and the handled-occurrence watermark still stops the occurrence just
|
||||
* closed from ringing twice.
|
||||
*
|
||||
* A *ringing* alarm is the exception. Deleting it stopped the ring, which is
|
||||
* a dismissal in all but name, so it comes back as a dismissal would have
|
||||
* left it: cycle closed, a one-shot switched off, never ringing again. A
|
||||
* transient one-shot (M9 D10) would have been deleted by that dismissal
|
||||
* anyway, so there is nothing to undo.
|
||||
*/
|
||||
fun snapshot(alarm: Alarm, state: AlarmRingState): DeletedAlarm? {
|
||||
if (!state.isRinging) return DeletedAlarm(alarm, state)
|
||||
|
||||
val closed = state.copy(ringingSince = null, snoozedUntil = null, snoozeCount = 0)
|
||||
if (alarm.repeatDays.isRepeating) return DeletedAlarm(alarm, closed)
|
||||
if (alarm.deleteAfterUse) return null
|
||||
return DeletedAlarm(alarm.copy(enabled = false), closed)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.PauseRange
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import java.time.LocalDate
|
||||
@@ -33,6 +34,13 @@ object AlarmOccurrences {
|
||||
* alarm is just "the next daily candidate", and what makes it one-shot is
|
||||
* what happens after it rings.
|
||||
*
|
||||
* [notBefore] is a scheduled date: no candidate on an earlier local date.
|
||||
* It is a floor, not a filter, so the sequence stays infinite — a date that
|
||||
* has already gone by (the phone was off all day) leaves the alarm behaving
|
||||
* as the plain one-shot it would be without one. [pause] drops every
|
||||
* candidate whose local date falls inside it; it is jumped, not walked, so
|
||||
* a corrupt pause centuries long costs nothing.
|
||||
*
|
||||
* Two local dates can collapse onto one instant (a skipped calendar day, a
|
||||
* gap), so a candidate that did not advance is dropped and the caller may
|
||||
* rely on `drop(1)` meaning "the one after".
|
||||
@@ -42,13 +50,21 @@ object AlarmOccurrences {
|
||||
repeatDays: RepeatDays,
|
||||
after: Instant,
|
||||
zone: ZoneId,
|
||||
notBefore: LocalDate? = null,
|
||||
pause: PauseRange? = null,
|
||||
): Sequence<Instant> = Sequence {
|
||||
iterator {
|
||||
// One day back, because a gap can push a candidate from the previous
|
||||
// local date past [after].
|
||||
var date = after.localDate(zone).minusDays(1)
|
||||
var date = firstDateFor(after, zone).let { first ->
|
||||
if (notBefore != null && notBefore.isAfter(first)) notBefore else first
|
||||
}
|
||||
var previous: Instant? = null
|
||||
while (true) {
|
||||
if (pause != null && date in pause) {
|
||||
date = pause.end.plusDays(1)
|
||||
continue
|
||||
}
|
||||
if (!repeatDays.isRepeating || date.dayOfWeek in repeatDays) {
|
||||
val candidate = at(date, time, zone)
|
||||
val last = previous
|
||||
@@ -67,7 +83,16 @@ object AlarmOccurrences {
|
||||
repeatDays: RepeatDays,
|
||||
after: Instant,
|
||||
zone: ZoneId,
|
||||
): Instant? = sequence(time, repeatDays, after, zone).firstOrNull()
|
||||
notBefore: LocalDate? = null,
|
||||
pause: PauseRange? = null,
|
||||
): Instant? = sequence(time, repeatDays, after, zone, notBefore, pause).firstOrNull()
|
||||
|
||||
/**
|
||||
* The earliest local date [sequence] looks at for [after]. A scheduled date
|
||||
* or a pause ending before it can no longer change a single candidate, which
|
||||
* is what makes it safe to clear one there.
|
||||
*/
|
||||
fun firstDateFor(after: Instant, zone: ZoneId): LocalDate = after.localDate(zone).minusDays(1)
|
||||
|
||||
private fun Instant.localDate(zone: ZoneId): LocalDate =
|
||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
|
||||
|
||||
@@ -18,6 +18,10 @@ data class AlarmResolution(
|
||||
val state: AlarmRingState,
|
||||
val clearSkipFlag: Boolean = false,
|
||||
val disableAlarm: Boolean = false,
|
||||
/** The scheduled date can no longer change a candidate: drop it from the row. */
|
||||
val clearScheduledDate: Boolean = false,
|
||||
/** The pause has ended: drop it from the row. */
|
||||
val clearPause: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -27,6 +31,10 @@ data class AlarmResolution(
|
||||
*
|
||||
* The precedence is fixed and total: disabled, then ringing, then snoozed, then
|
||||
* the natural occurrence.
|
||||
*
|
||||
* `autoSilenceAfter` is the "Silence after" setting's window
|
||||
* (`ClockDefaults.alarmSilenceAfter`). It has no default, so no caller can
|
||||
* measure a ring against a window the user did not choose.
|
||||
*/
|
||||
object AlarmResolver {
|
||||
fun resolve(
|
||||
@@ -34,8 +42,31 @@ object AlarmResolver {
|
||||
state: AlarmRingState,
|
||||
now: Instant,
|
||||
zone: ZoneId,
|
||||
autoSilenceAfter: Duration = AlarmRing.AUTO_SILENCE_AFTER,
|
||||
autoSilenceAfter: Duration,
|
||||
fireGrace: Duration = AlarmRing.FIRE_GRACE,
|
||||
): AlarmResolution {
|
||||
// A date or a pause that lies wholly before the first date the
|
||||
// candidates are drawn from cannot change one of them, so clearing it
|
||||
// changes nothing but the row — on any branch, a disabled alarm too.
|
||||
// Measured from the same `now - fireGrace` the candidates are, so a
|
||||
// pause ending at midnight cannot let its last day's occurrence ring
|
||||
// late through the grace window.
|
||||
val firstDate = AlarmOccurrences.firstDateFor(now - fireGrace, zone)
|
||||
val scheduledDate = alarm.scheduledDate
|
||||
val pause = alarm.pause
|
||||
return resolveRing(alarm, state, now, zone, autoSilenceAfter, fireGrace).copy(
|
||||
clearScheduledDate = scheduledDate != null && !scheduledDate.isAfter(firstDate),
|
||||
clearPause = pause != null && pause.end.isBefore(firstDate),
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveRing(
|
||||
alarm: Alarm,
|
||||
state: AlarmRingState,
|
||||
now: Instant,
|
||||
zone: ZoneId,
|
||||
autoSilenceAfter: Duration,
|
||||
fireGrace: Duration,
|
||||
): AlarmResolution {
|
||||
// 1. Disabled: every volatile field goes, except the dismissal watermark
|
||||
// — re-enabling an alarm must not un-suppress an occurrence already rung.
|
||||
@@ -68,8 +99,11 @@ object AlarmResolver {
|
||||
|
||||
// 3. Snooze. A snooze promised before a reboot is kept, even if it came
|
||||
// due while the device was off; one older than the ring window is gone.
|
||||
// Never sooner than SNOOZE_GRACE, so a short "Silence after" cannot make
|
||||
// a snooze easier to lose than it was in v1.
|
||||
val snoozedUntil = working.snoozedUntil
|
||||
val snoozePending = snoozedUntil != null && now - snoozedUntil < autoSilenceAfter
|
||||
val snoozeGrace = AlarmRing.snoozeGrace(autoSilenceAfter)
|
||||
val snoozePending = snoozedUntil != null && now - snoozedUntil < snoozeGrace
|
||||
if (snoozedUntil != null && !snoozePending) {
|
||||
working = working.copy(snoozedUntil = null, snoozeCount = 0)
|
||||
}
|
||||
@@ -89,6 +123,22 @@ object AlarmResolver {
|
||||
}
|
||||
}
|
||||
|
||||
// A cycle that has just closed as missed (a ring past its window, or a
|
||||
// snooze that never fired) closes a one-shot alarm for good, exactly as
|
||||
// the engine's own close does. Falling through would hand it the same
|
||||
// time tomorrow.
|
||||
val closedAsMissed = ringingSince != null || snoozedUntil != null
|
||||
if (closedAsMissed && !alarm.repeatDays.isRepeating) {
|
||||
return AlarmResolution(
|
||||
alarmId = alarm.id,
|
||||
nextFire = null,
|
||||
source = FireSource.NONE,
|
||||
state = working.copy(skippedOccurrence = null),
|
||||
clearSkipFlag = alarm.skipNextOccurrence,
|
||||
disableAlarm = true,
|
||||
)
|
||||
}
|
||||
|
||||
// 4. The natural occurrence, with the skip watermark on top.
|
||||
if (!alarm.skipNextOccurrence) {
|
||||
return AlarmResolution(
|
||||
@@ -120,7 +170,17 @@ object AlarmResolver {
|
||||
// cannot ring on its way out through the grace window.
|
||||
nextFire = candidates(alarm, working, now, zone, fireGrace).first { it > watermark },
|
||||
source = FireSource.OCCURRENCE,
|
||||
state = working.copy(skippedOccurrence = watermark.takeUnless { consumed }),
|
||||
// A consumed skip becomes the handled-occurrence watermark: with both
|
||||
// the flag and the skip watermark gone, nothing else would stop the
|
||||
// next resolution inside the fire grace from ringing it after all.
|
||||
state = if (consumed) {
|
||||
working.copy(
|
||||
skippedOccurrence = null,
|
||||
handledOccurrence = maxOf(watermark, working.handledOccurrence ?: watermark),
|
||||
)
|
||||
} else {
|
||||
working.copy(skippedOccurrence = watermark)
|
||||
},
|
||||
clearSkipFlag = consumed,
|
||||
)
|
||||
}
|
||||
@@ -139,7 +199,14 @@ object AlarmResolver {
|
||||
fireGrace: Duration,
|
||||
): Sequence<Instant> {
|
||||
val handled = state.handledOccurrence
|
||||
return AlarmOccurrences.sequence(alarm.time, alarm.repeatDays, now - fireGrace, zone)
|
||||
return AlarmOccurrences.sequence(
|
||||
time = alarm.time,
|
||||
repeatDays = alarm.repeatDays,
|
||||
after = now - fireGrace,
|
||||
zone = zone,
|
||||
notBefore = alarm.scheduledDate,
|
||||
pause = alarm.pause,
|
||||
)
|
||||
.filterNot { handled != null && it <= handled && it <= now }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,20 +4,27 @@ import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* The alarm ring cycle's constants. They are values rather than settings: v1
|
||||
* does not offer them to the user (docs/PLAN.md §4). The audio chain's own
|
||||
* constants live in `domain/ring/RingAudio`, shared with the timer's ring.
|
||||
* The alarm ring cycle's constants. They are values rather than settings
|
||||
* (docs/PLAN.md §4). How long an alarm rings before it is silenced as missed
|
||||
* became a setting in #17: `ClockDefaults.alarmSilenceAfter`. The audio
|
||||
* chain's own constants live in `domain/ring/RingAudio`, shared with the
|
||||
* timer's ring.
|
||||
*/
|
||||
object AlarmRing {
|
||||
/**
|
||||
* How long an undismissed alarm rings before it is silenced as missed.
|
||||
* AOSP DeskClock's default, and the window a resumed ring is measured against.
|
||||
*/
|
||||
val AUTO_SILENCE_AFTER: Duration = 10.minutes
|
||||
|
||||
/**
|
||||
* How late a fire may arrive and still count. A fire delayed by doze, a slow
|
||||
* boot or a `TIME_SET` nudge still rings; one missed by hours does not.
|
||||
*/
|
||||
val FIRE_GRACE: Duration = 2.minutes
|
||||
|
||||
/**
|
||||
* The least time a late snooze is still kept: v1's fixed ring window. A
|
||||
* snooze delayed by a reboot or by Doze is kept for the "Silence after"
|
||||
* window or this, whichever is longer, so a short window never makes a
|
||||
* promised snooze easier to lose.
|
||||
*/
|
||||
val SNOOZE_GRACE: Duration = 10.minutes
|
||||
|
||||
/** How late a snooze may come due and still ring, given the ring window. */
|
||||
fun snoozeGrace(silenceAfter: Duration): Duration = maxOf(silenceAfter, SNOOZE_GRACE)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
import de.jeanlucmakiola.clockula.domain.PauseRange
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* What an alarm's scheduled date and pause mean *right now*, for the surfaces
|
||||
* that describe an alarm. The resolver decides when it rings; this only keeps
|
||||
* the words beside it honest. A stored date or pause outlives its usefulness
|
||||
* by up to a day before the resolver clears it, and in that window it must not
|
||||
* be shown as though it still applied.
|
||||
*/
|
||||
object AlarmSchedule {
|
||||
|
||||
/**
|
||||
* The local date [now] falls on in [zone].
|
||||
*/
|
||||
fun today(now: Instant, zone: ZoneId): LocalDate =
|
||||
java.time.Instant.ofEpochMilli(now.toEpochMilliseconds()).atZone(zone).toLocalDate()
|
||||
|
||||
/**
|
||||
* A one-shot alarm's scheduled date while its occurrence is still ahead.
|
||||
* Null once it has gone by: the alarm then rings like a plain one-shot,
|
||||
* and saying "Scheduled for" a day in the past would be a lie.
|
||||
*/
|
||||
fun upcomingDate(alarm: Alarm, now: Instant, zone: ZoneId): LocalDate? {
|
||||
if (alarm.repeatDays.isRepeating) return null
|
||||
val date = alarm.scheduledDate ?: return null
|
||||
return date.takeIf { isAhead(it, alarm.time, now, zone) }
|
||||
}
|
||||
|
||||
/** Whether [time] on [date] is still to come — the one test a scheduled date must pass. */
|
||||
fun isAhead(date: LocalDate, time: TimeOfDay, now: Instant, zone: ZoneId): Boolean =
|
||||
AlarmOccurrences.at(date, time, zone) > now
|
||||
|
||||
/** A repeating alarm's pause while any day of it is still to come — today included. */
|
||||
fun currentPause(alarm: Alarm, now: Instant, zone: ZoneId): PauseRange? {
|
||||
if (!alarm.repeatDays.isRepeating) return null
|
||||
val pause = alarm.pause ?: return null
|
||||
return pause.takeUnless { it.end.isBefore(today(now, zone)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The first date a one-shot alarm at [time] can be scheduled on: the date
|
||||
* of its next occurrence. Today while today's [time] is still ahead,
|
||||
* tomorrow once it has gone by — so the picker cannot offer a date whose
|
||||
* alarm would already be in the past.
|
||||
*/
|
||||
fun earliestDate(time: TimeOfDay, now: Instant, zone: ZoneId): LocalDate {
|
||||
// Every day matches RepeatDays.NONE, so the sequence is never empty.
|
||||
return today(AlarmOccurrences.sequence(time, RepeatDays.NONE, now, zone).first(), zone)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.format.NextFireFormat
|
||||
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* What "Alarm in 7 hours and 12 minutes" says once the user has
|
||||
* set an alarm, or null when it should say nothing (#19).
|
||||
*
|
||||
* Only a natural occurrence is announced. A switched-off alarm will not ring at
|
||||
* all; a ringing or snoozed one is answering the snooze, not the change the
|
||||
* user just made, and "Alarm in 9 minutes" would read as a mistake.
|
||||
*/
|
||||
object AlarmSetCountdown {
|
||||
/** Pure. The rounding is [NextFireFormat]'s, so the chip and the row agree. */
|
||||
fun of(upcoming: UpcomingAlarm?, now: Instant): NextFireLabel? {
|
||||
// A switched-off alarm resolves to FireSource.NONE, so this one check
|
||||
// also covers it.
|
||||
if (upcoming?.source != FireSource.OCCURRENCE) return null
|
||||
val nextFire = upcoming.nextFire ?: return null
|
||||
return NextFireFormat.labelFor(nextFire, now)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,37 @@ object RingPresentationPolicy {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What decides whether a ring service opens its screen itself rather than
|
||||
* leaving it to the full-screen intent. All read at the moment of the ring.
|
||||
*/
|
||||
data class DirectLaunchFacts(
|
||||
/** Display on and unlocked: the one state where a full-screen intent is only a heads-up. */
|
||||
val phoneInUse: Boolean,
|
||||
/** One of Clockula's own activities is started, which lets it start another. */
|
||||
val appVisible: Boolean,
|
||||
/** The ring screen for this very ring is already up. */
|
||||
val screenShowing: Boolean,
|
||||
/** The user's "Ring screen over other apps". */
|
||||
val overOtherApps: Boolean,
|
||||
/** "Display over other apps", without which [overOtherApps] can do nothing. */
|
||||
val canDrawOverlays: Boolean,
|
||||
/** A call is in progress, whose screen a ring must not cover. */
|
||||
val inCall: Boolean,
|
||||
)
|
||||
|
||||
object DirectLaunchPolicy {
|
||||
/**
|
||||
* A locked or dark phone is the full-screen intent's, so the screen never
|
||||
* opens twice; an open screen is left alone, so a restart never resets it.
|
||||
* Over another app only when the user asked for it, the permission is
|
||||
* there, and no call is going on.
|
||||
*/
|
||||
fun opensDirectly(facts: DirectLaunchFacts): Boolean = with(facts) {
|
||||
phoneInUse && !screenShowing && (appVisible || (overOtherApps && canDrawOverlays && !inCall))
|
||||
}
|
||||
}
|
||||
|
||||
enum class ScheduleMode { EXACT_ALARM_CLOCK, INEXACT_FALLBACK }
|
||||
|
||||
object SchedulingPolicy {
|
||||
|
||||
@@ -23,6 +23,8 @@ data class RingSession(
|
||||
val settings: AlarmSettings,
|
||||
val startedAt: Instant,
|
||||
val canSnooze: Boolean,
|
||||
/** The app-wide volume-button setting, read with the rest of the session. */
|
||||
val volumeButtons: VolumeButtonAction = VolumeButtonAction.CONTROL_VOLUME,
|
||||
)
|
||||
|
||||
sealed interface FireOutcome {
|
||||
@@ -43,6 +45,12 @@ sealed interface DismissOutcome {
|
||||
|
||||
/** Unknown id, or an alarm with no upcoming instance. Nothing was written. */
|
||||
data object NotFound : DismissOutcome
|
||||
|
||||
/**
|
||||
* The occurrence a notice or row named is no longer the one to come: it
|
||||
* started ringing, or the alarm moved. Nothing was dismissed (#16).
|
||||
*/
|
||||
data object Stale : DismissOutcome
|
||||
}
|
||||
|
||||
sealed interface SnoozeOutcome {
|
||||
@@ -50,5 +58,9 @@ sealed interface SnoozeOutcome {
|
||||
|
||||
/** The limit was already reached; the alarm was dismissed instead. */
|
||||
data class LimitReached(val alarmId: Long) : SnoozeOutcome
|
||||
|
||||
/** The limit was already reached and the caller asked for the alarm to keep ringing. */
|
||||
data class Refused(val alarmId: Long) : SnoozeOutcome
|
||||
|
||||
data object NotRinging : SnoozeOutcome
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** One alarm about to ring, offered for dismissal before it does (#16). */
|
||||
data class UpcomingNotice(
|
||||
val alarm: Alarm,
|
||||
/** The occurrence the notice is about; a dismissal names it, so a stale tap cannot reach a later one. */
|
||||
val fireAt: Instant,
|
||||
)
|
||||
|
||||
/**
|
||||
* What the upcoming-alarm surface should show now, and when it next has to
|
||||
* look again. [nextCheckAt] is null when there is nothing left to look at.
|
||||
*/
|
||||
data class UpcomingNoticePlan(
|
||||
val notices: List<UpcomingNotice>,
|
||||
val nextCheckAt: Instant?,
|
||||
)
|
||||
|
||||
/**
|
||||
* The "upcoming alarm" window: the stretch before an alarm rings in which it
|
||||
* can be dismissed without opening the app (#16). Pure; the engine and the
|
||||
* alarm list both ask it, so the notification and the row's chip agree.
|
||||
*/
|
||||
object UpcomingNotices {
|
||||
/**
|
||||
* How long before the ring the notice appears. AOSP DeskClock posts its
|
||||
* "Upcoming alarm" notification two hours ahead, which is also what Google
|
||||
* Clock is reported to do. Long enough to cover waking early; the notice
|
||||
* is silent, so being early costs nothing. A value, not a setting: the
|
||||
* notification channel is the off switch.
|
||||
*/
|
||||
val LEAD: Duration = 2.hours
|
||||
|
||||
/**
|
||||
* Only a natural occurrence still to come. A snoozed alarm already has its
|
||||
* own notification with a Dismiss, and a ringing one is the ring screen's,
|
||||
* where the dismiss challenge is.
|
||||
*/
|
||||
fun isInWindow(nextFire: Instant?, source: FireSource, now: Instant, lead: Duration = LEAD): Boolean =
|
||||
source == FireSource.OCCURRENCE && nextFire != null && nextFire > now && nextFire - now <= lead
|
||||
|
||||
/**
|
||||
* Notices ordered by when they ring, then id. The next check is the
|
||||
* earliest of three instants still to come:
|
||||
*
|
||||
* - a window opening, so its notice goes up;
|
||||
* - a noticed occurrence's fire grace running out, so a notice whose fire
|
||||
* came late (Doze, no exact alarms) does not outlive its alarm;
|
||||
* - an armed skip's ([pendingSkips]) fire grace running out, so a skip
|
||||
* dismissed in advance is spent on time and the list stops saying
|
||||
* "skipping next" about an occurrence that has gone by.
|
||||
*
|
||||
* Both of the last two wait out the grace rather than land on the
|
||||
* occurrence itself: a fire for it still in flight is refused as too late
|
||||
* by then, so the check can never race it.
|
||||
*/
|
||||
fun plan(
|
||||
upcoming: List<UpcomingAlarm>,
|
||||
now: Instant,
|
||||
pendingSkips: List<Instant> = emptyList(),
|
||||
lead: Duration = LEAD,
|
||||
fireGrace: Duration = AlarmRing.FIRE_GRACE,
|
||||
): UpcomingNoticePlan {
|
||||
val notices = upcoming
|
||||
.filter { isInWindow(it.nextFire, it.source, now, lead) }
|
||||
.map { UpcomingNotice(it.alarm, it.nextFire!!) }
|
||||
.sortedWith(compareBy({ it.fireAt }, { it.alarm.id }))
|
||||
val openings = upcoming
|
||||
.filter { it.source == FireSource.OCCURRENCE }
|
||||
.mapNotNull { entry -> entry.nextFire?.let { it - lead } }
|
||||
// Strictly past the grace: `onFire` still accepts a fire at exactly it.
|
||||
val afterGrace = fireGrace + 1.seconds
|
||||
val closings = notices.map { it.fireAt + afterGrace }
|
||||
val skipsSpent = pendingSkips.map { it + afterGrace }
|
||||
val nextCheckAt = (openings + closings + skipsSpent).filter { it > now }.minOrNull()
|
||||
return UpcomingNoticePlan(notices, nextCheckAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
|
||||
/**
|
||||
* What the user asked the volume buttons to do while an alarm rings (#18).
|
||||
* App-wide, not per alarm: it is a habit of the hand, not a property of one
|
||||
* alarm. [CONTROL_VOLUME] is the default and is what Clockula did before the
|
||||
* setting existed.
|
||||
*/
|
||||
enum class VolumeButtonAction { CONTROL_VOLUME, SNOOZE, DISMISS, NOTHING }
|
||||
|
||||
/** What one press actually does, once the ring's own rules have had their say. */
|
||||
enum class VolumeKeyEffect {
|
||||
/** Not ours: the system changes the volume as it would anyway. */
|
||||
PASS_THROUGH,
|
||||
|
||||
/** Swallowed, so the volume stays where it is and the alarm keeps ringing ("Do nothing" only). */
|
||||
IGNORE,
|
||||
SNOOZE,
|
||||
DISMISS,
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the setting into an effect for one ringing alarm. The press is blind
|
||||
* (the point is not having to look at the screen), so it must never end the
|
||||
* alarm more finally than the ring screen itself would:
|
||||
*
|
||||
* - **Dismiss** dismisses only when the alarm has no dismiss challenge. A
|
||||
* button that skipped the sum or the hold would make the challenge
|
||||
* pointless, so with one set it snoozes instead, and the challenge waits for
|
||||
* the next ring.
|
||||
* - **Snooze** with no snoozes left (and Dismiss behind a challenge with none
|
||||
* left) falls back to changing the volume. The engine answers a refused
|
||||
* snooze from the Snooze button by dismissing, which is right for a button
|
||||
* the user can see is gone, and wrong for a press made with eyes closed: the
|
||||
* alarm would end for good without the user knowing. Swallowing the key
|
||||
* instead would take away the one thing it could still do. The caller also
|
||||
* asks the engine to keep ringing (`AlarmEngine.snooze`'s
|
||||
* `dismissIfRefused`), so a budget that ran out after this was decided does
|
||||
* not end the alarm either.
|
||||
*/
|
||||
object VolumeButtonPolicy {
|
||||
|
||||
fun effect(
|
||||
action: VolumeButtonAction,
|
||||
canSnooze: Boolean,
|
||||
challenge: DismissChallenge,
|
||||
): VolumeKeyEffect = when (action) {
|
||||
VolumeButtonAction.CONTROL_VOLUME -> VolumeKeyEffect.PASS_THROUGH
|
||||
VolumeButtonAction.NOTHING -> VolumeKeyEffect.IGNORE
|
||||
VolumeButtonAction.SNOOZE -> snoozeOrVolume(canSnooze)
|
||||
VolumeButtonAction.DISMISS ->
|
||||
if (challenge == DismissChallenge.NONE) VolumeKeyEffect.DISMISS else snoozeOrVolume(canSnooze)
|
||||
}
|
||||
|
||||
fun effect(session: RingSession): VolumeKeyEffect =
|
||||
effect(session.volumeButtons, session.canSnooze, session.settings.dismissChallenge)
|
||||
|
||||
private fun snoozeOrVolume(canSnooze: Boolean): VolumeKeyEffect =
|
||||
if (canSnooze) VolumeKeyEffect.SNOOZE else VolumeKeyEffect.PASS_THROUGH
|
||||
}
|
||||
@@ -2,9 +2,14 @@ package de.jeanlucmakiola.clockula.domain.backup
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.PauseRange
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
|
||||
import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetSettings
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import java.time.LocalDate
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -35,6 +40,8 @@ data class BackupAlarm(
|
||||
val volumeRampSeconds: Int?,
|
||||
val dismissChallenge: DismissChallenge?,
|
||||
val deleteAfterUse: Boolean,
|
||||
val scheduledDate: LocalDate? = null,
|
||||
val pause: PauseRange? = null,
|
||||
)
|
||||
|
||||
data class BackupTimer(
|
||||
@@ -56,4 +63,11 @@ data class BackupSettings(
|
||||
val dynamicColor: Boolean,
|
||||
val defaults: ClockDefaults,
|
||||
val timerPresets: List<Duration>,
|
||||
/** The world clock's "Display time with seconds" (#23). */
|
||||
val worldClockSeconds: Boolean,
|
||||
val screenSaver: ScreenSaverSettings,
|
||||
/** What the quick-timer Quick Settings tile starts (#26). */
|
||||
val quickTileDuration: Duration = QuickTimerWidgetSettings.DEFAULT,
|
||||
/** Null for a file written before bedtime existed: restoring it keeps the current schedule. */
|
||||
val bedtime: BedtimeSettings? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package de.jeanlucmakiola.clockula.domain.bedtime
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmOccurrences
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.toJavaInstant
|
||||
|
||||
/** One night of bedtime: on from [start] (inclusive) to [end] (exclusive). */
|
||||
data class BedtimeWindow(val start: Instant, val end: Instant) {
|
||||
operator fun contains(instant: Instant): Boolean = instant >= start && instant < end
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a bedtime schedule stands at a given instant. Like the alarm path,
|
||||
* every window is re-resolved from local times and the zone, never by adding
|
||||
* a fixed length to the last one, so a DST night is as long as the clock
|
||||
* says it is.
|
||||
*/
|
||||
object BedtimeSchedule {
|
||||
|
||||
/**
|
||||
* The window for the night of [date], if [date] is one of the schedule's
|
||||
* nights. A night is named by its evening, as people name it: a bedtime
|
||||
* in the small hours (before 06:00; 00:30, say) is *after* that evening,
|
||||
* so "Thursday night" at 00:30 starts on Friday's calendar date. A later
|
||||
* one (a day sleeper's 08:00) is on the day itself. It ends at the first wake time after it starts, so a wake time
|
||||
* before the bedtime means the next morning. An equal bedtime and wake
|
||||
* time is no window at all: read as a whole day, every night selected
|
||||
* would join into a Do Not Disturb that never ends.
|
||||
*/
|
||||
fun windowOn(settings: BedtimeSettings, date: LocalDate, zone: ZoneId): BedtimeWindow? {
|
||||
if (date.dayOfWeek !in settings.days || settings.bedtime == settings.wakeTime) return null
|
||||
// Next morning or same day is decided on the local times, before DST
|
||||
// resolution: a bedtime in a spring-forward gap shifts later and must
|
||||
// not push a same-night wake time to the following day.
|
||||
val startDate = if (settings.bedtime.minutesOfDay < SMALL_HOURS_END) date.plusDays(1) else date
|
||||
val endDate =
|
||||
if (settings.wakeTime.minutesOfDay > settings.bedtime.minutesOfDay) startDate else startDate.plusDays(1)
|
||||
val start = AlarmOccurrences.at(startDate, settings.bedtime, zone)
|
||||
val end = AlarmOccurrences.at(endDate, settings.wakeTime, zone)
|
||||
return if (end > start) BedtimeWindow(start, end) else null
|
||||
}
|
||||
|
||||
/** The window [now] falls in, or null when it is daytime or the schedule is off. */
|
||||
fun activeWindow(settings: BedtimeSettings, now: Instant, zone: ZoneId): BedtimeWindow? {
|
||||
if (!settings.enabled) return null
|
||||
val today = today(now, zone)
|
||||
// A window is at most a day long and starts on its night's date or
|
||||
// the day after, so one that contains now belongs to one of the last
|
||||
// three nights.
|
||||
return listOf(today, today.minusDays(1), today.minusDays(2))
|
||||
.firstNotNullOfOrNull { date -> windowOn(settings, date, zone)?.takeIf { now in it } }
|
||||
}
|
||||
|
||||
/** The first window that starts strictly after [now], within the coming week. */
|
||||
fun nextWindow(settings: BedtimeSettings, now: Instant, zone: ZoneId): BedtimeWindow? {
|
||||
if (!settings.enabled) return null
|
||||
val today = today(now, zone)
|
||||
return (-1L..7L)
|
||||
.firstNotNullOfOrNull { offset ->
|
||||
windowOn(settings, today.plusDays(offset), zone)?.takeIf { it.start > now }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The upcoming window whose reminder is due at [now]: [now] lies between
|
||||
* its reminder time and its start. Null when reminders are off, or when
|
||||
* [now] is already inside a window (the reminder has nothing left to say).
|
||||
*/
|
||||
fun dueReminder(settings: BedtimeSettings, now: Instant, zone: ZoneId): BedtimeWindow? {
|
||||
if (!settings.enabled || settings.reminderMinutes <= 0) return null
|
||||
if (activeWindow(settings, now, zone) != null) return null
|
||||
val next = nextWindow(settings, now, zone) ?: return null
|
||||
return next.takeIf { now >= reminderAt(settings, next) }
|
||||
}
|
||||
|
||||
/**
|
||||
* When the schedule next needs attention: the end of the current window,
|
||||
* the next window's reminder or the next window's start, whichever comes
|
||||
* first. Null when there is nothing to schedule.
|
||||
*/
|
||||
fun nextEvent(settings: BedtimeSettings, now: Instant, zone: ZoneId): Instant? {
|
||||
if (!settings.enabled) return null
|
||||
val active = activeWindow(settings, now, zone)
|
||||
val next = nextWindow(settings, now, zone)
|
||||
val reminder = next
|
||||
?.takeIf { settings.reminderMinutes > 0 }
|
||||
?.let { reminderAt(settings, it) }
|
||||
?.takeIf { it > now }
|
||||
return listOfNotNull(active?.end, reminder, next?.start).minOrNull()
|
||||
}
|
||||
|
||||
private fun reminderAt(settings: BedtimeSettings, window: BedtimeWindow): Instant =
|
||||
window.start - settings.reminderMinutes.minutes
|
||||
|
||||
private const val SMALL_HOURS_END = 6 * 60
|
||||
|
||||
private fun today(now: Instant, zone: ZoneId): LocalDate = now.toJavaInstant().atZone(zone).toLocalDate()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.jeanlucmakiola.clockula.domain.bedtime
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
|
||||
/**
|
||||
* The bedtime schedule (issue #1): an opt-in Do Not Disturb mode Clockula
|
||||
* turns on at [bedtime] and off at [wakeTime]. [days] are the nights the
|
||||
* mode *starts* on, so a Friday-only schedule runs from Friday evening into
|
||||
* Saturday morning.
|
||||
*/
|
||||
data class BedtimeSettings(
|
||||
val enabled: Boolean = false,
|
||||
val bedtime: TimeOfDay = TimeOfDay(22, 30),
|
||||
val wakeTime: TimeOfDay = TimeOfDay(7, 0),
|
||||
val days: RepeatDays = RepeatDays.EVERY_DAY,
|
||||
/** How long before [bedtime] to post a reminder; 0 is off. */
|
||||
val reminderMinutes: Int = 0,
|
||||
val effects: BedtimeEffects = BedtimeEffects(),
|
||||
)
|
||||
|
||||
/**
|
||||
* The screen effects a mode can carry on Android 15 and later
|
||||
* (`ZenDeviceEffects`). Earlier versions keep the setting and ignore it.
|
||||
*/
|
||||
data class BedtimeEffects(
|
||||
val grayscale: Boolean = true,
|
||||
val dimWallpaper: Boolean = true,
|
||||
val darkTheme: Boolean = true,
|
||||
val hideAlwaysOnDisplay: Boolean = false,
|
||||
)
|
||||
|
||||
object BedtimeLimits {
|
||||
/** The reminder lead times the picker offers, in minutes; 0 is off. */
|
||||
val REMINDER_CHOICES: List<Int> = listOf(0, 15, 30, 45, 60)
|
||||
|
||||
/** Anything read from disk or a backup is clamped into this. */
|
||||
val REMINDER_MINUTES: IntRange = 0..120
|
||||
|
||||
/**
|
||||
* The nights from a stored mask. The screen never lets the last night go,
|
||||
* so an empty mask (corrupt, or written around it) reads as every night,
|
||||
* on disk and in a backup alike.
|
||||
*/
|
||||
fun nights(mask: Int): RepeatDays = RepeatDays.fromMask(mask).takeIf { it.isRepeating } ?: RepeatDays.EVERY_DAY
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.clockula.domain.interop
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.ClockLimits
|
||||
|
||||
/** The bounds an extra from another process is read against (M9 D5, D19). */
|
||||
object InteropLimits {
|
||||
const val MAX_LABEL_LENGTH: Int = 256
|
||||
@@ -12,7 +14,7 @@ object InteropLimits {
|
||||
val TIMER_SECONDS: IntRange = 1..86_400
|
||||
|
||||
/** `ClockPrefs`' own clamp: an intent cannot ask for a snooze the user could not choose. */
|
||||
val SNOOZE_MINUTES: IntRange = 1..60
|
||||
val SNOOZE_MINUTES: IntRange = ClockLimits.SNOOZE_MINUTES
|
||||
|
||||
val RINGTONE_SCHEMES: List<String> = listOf("content://", "android.resource://")
|
||||
}
|
||||
|
||||
@@ -60,6 +60,17 @@ object MediaRingPolicy {
|
||||
|
||||
fun shouldFallBack(stall: Stall): Boolean = stall.stopped >= STALL_GRACE || stall.notPlaying >= BUFFER_GRACE
|
||||
|
||||
/**
|
||||
* Whether the app turned down the ring's play request, so the start can
|
||||
* fall back now instead of waiting out [START_TIMEOUT]. YouTube without
|
||||
* Premium does this at once. Only an error reported at or after
|
||||
* [requestedAtMillis] counts: one left over from the user's own listening
|
||||
* says nothing about this request. Both times are `elapsedRealtime`, which
|
||||
* is what `PlaybackState` stamps its updates with.
|
||||
*/
|
||||
fun refusedStart(isError: Boolean, updatedAtMillis: Long, requestedAtMillis: Long): Boolean =
|
||||
isError && updatedAtMillis >= requestedAtMillis
|
||||
|
||||
/**
|
||||
* The media-stream index a fully ramped ring plays at.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.jeanlucmakiola.clockula.domain.ring
|
||||
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* How long a ring keeps going before it stops on its own: the "Silence after"
|
||||
* setting, one for alarms and one for timers (#17). Stored as whole minutes,
|
||||
* with [NEVER] for the option that has no user-facing limit.
|
||||
*
|
||||
* "Never" is not unbounded. The auto-silence backstop is also what stops a
|
||||
* phone left at home from ringing until its battery is flat, and what keeps a
|
||||
* reboot from resuming a ring that started hours ago, so "Never" rings until
|
||||
* [HARD_STOP] — longer than any finite choice — and then stops as any other
|
||||
* window would.
|
||||
*/
|
||||
object SilenceAfter {
|
||||
/** The stored value for "Never". Zero, because no window can be zero minutes long. */
|
||||
const val NEVER: Int = 0
|
||||
|
||||
/** The default, for alarms and timers alike: AOSP DeskClock's, and v1's fixed value. */
|
||||
const val DEFAULT_MINUTES: Int = 10
|
||||
|
||||
/**
|
||||
* The longest a ring ever lasts, in minutes, and what "Never" means in
|
||||
* practice. The settings hints say "an hour" in words; change them with it.
|
||||
*/
|
||||
const val HARD_STOP_MINUTES: Int = 60
|
||||
|
||||
val HARD_STOP: Duration = HARD_STOP_MINUTES.minutes
|
||||
|
||||
/**
|
||||
* How long the ring services hold their wake locks: a backstop on the
|
||||
* backstop, past the longest window there is, so a ring is never cut
|
||||
* short by its own wake lock.
|
||||
*/
|
||||
val WAKE_LOCK_TIMEOUT: Duration = HARD_STOP + 5.minutes
|
||||
|
||||
/** The range a stored value is measured against: below it is corrupt, and [sanitise] stores the hard stop itself as [NEVER]. */
|
||||
val MINUTES: IntRange = NEVER..HARD_STOP_MINUTES
|
||||
|
||||
/** The choices the pickers offer, in order: Google Clock's, so nothing here surprises. */
|
||||
val CHOICES: List<Int> = listOf(1, 5, 10, 15, 20, 25, NEVER)
|
||||
|
||||
/**
|
||||
* What a stored value is read and written as, from a picker, a hand-edited
|
||||
* file or a backup. The hard stop or longer is "Never", which rings that
|
||||
* long anyway, so the picker never shows two names for one window. Negative is nothing anyone chose, so it is the default rather
|
||||
* than the clamp's floor, which would be "Never".
|
||||
*/
|
||||
fun sanitise(minutes: Int): Int = when {
|
||||
minutes < MINUTES.first -> DEFAULT_MINUTES
|
||||
minutes >= HARD_STOP_MINUTES -> NEVER
|
||||
else -> minutes
|
||||
}
|
||||
|
||||
/** The ring window [minutes] stands for, sanitised first so the engines never see an unofferable value. */
|
||||
fun window(minutes: Int): Duration {
|
||||
val sane = sanitise(minutes)
|
||||
return if (sane == NEVER) HARD_STOP else sane.minutes
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package de.jeanlucmakiola.clockula.domain.screensaver
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* How the screen saver names the day of the next alarm. A weekday alone
|
||||
* ("Thu") is only unambiguous for the coming six days: a weekly alarm whose
|
||||
* time has passed today next rings in seven, and a skipped one in fourteen,
|
||||
* and either would read as today's. Those carry their date as well.
|
||||
*/
|
||||
object ScreenSaverAlarmDay {
|
||||
|
||||
private const val DAYS_A_WEEKDAY_NAMES = 7L
|
||||
|
||||
fun weekdayIsEnough(fireDate: LocalDate, today: LocalDate): Boolean =
|
||||
fireDate.isBefore(today.plusDays(DAYS_A_WEEKDAY_NAMES))
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.jeanlucmakiola.clockula.domain.screensaver
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Where the screen saver's clock sits, so a face left up all night never
|
||||
* burns into an OLED panel: it moves to a new spot once a minute, as AOSP's
|
||||
* DeskClock screen saver does.
|
||||
*
|
||||
* A position is a pair of fractions of the *free* space — the screen minus
|
||||
* the clock itself — so `0f` is flush with the left/top edge and `1f` with
|
||||
* the right/bottom one, at any screen and clock size. Derived from the minute
|
||||
* alone rather than from a random source, so the clock lands on the same spot
|
||||
* however often the minute is re-read (a recomposition, a time change) and
|
||||
* moves only when the minute does.
|
||||
*/
|
||||
object ScreenSaverDrift {
|
||||
|
||||
data class Position(val x: Float, val y: Float)
|
||||
|
||||
/** The spot for the minute [now] falls in. Both fractions in `[0, 1]`. */
|
||||
fun positionAt(now: Instant): Position {
|
||||
val minute = Math.floorDiv(now.epochSeconds, SECONDS_PER_MINUTE)
|
||||
val bits = mix(minute)
|
||||
return Position(x = fraction(bits ushr 40), y = fraction(bits ushr 16))
|
||||
}
|
||||
|
||||
/** The low 24 bits of [bits] as a fraction in `[0, 1]`. */
|
||||
private fun fraction(bits: Long): Float = (bits and MASK_24).toFloat() / MASK_24
|
||||
|
||||
/**
|
||||
* SplitMix64's finaliser: consecutive minutes land on unrelated spots,
|
||||
* where a plain modulo would walk the clock across the screen in a line.
|
||||
*/
|
||||
private fun mix(seed: Long): Long {
|
||||
var z = seed + GOLDEN_GAMMA
|
||||
z = (z xor (z ushr 30)) * MIX_1
|
||||
z = (z xor (z ushr 27)) * MIX_2
|
||||
return z xor (z ushr 31)
|
||||
}
|
||||
|
||||
private const val SECONDS_PER_MINUTE = 60L
|
||||
private const val MASK_24 = 0xFFFFFFL
|
||||
private const val GOLDEN_GAMMA = -0x61c8864680b583ebL
|
||||
private const val MIX_1 = -0x40a7b892e31b1a47L
|
||||
private const val MIX_2 = -0x6b2fb644ecceee15L
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package de.jeanlucmakiola.clockula.domain.screensaver
|
||||
|
||||
/** The screen saver's two faces: the time as numerals, or the world clock's dial. */
|
||||
enum class ScreenSaverStyle { DIGITAL, ANALOG }
|
||||
|
||||
/**
|
||||
* What the screen saver shows. [nightMode] is Google Clock's "very dim
|
||||
* display, for dark rooms": dim colours on black and the display itself
|
||||
* turned down, for a phone on a bedside table.
|
||||
*/
|
||||
data class ScreenSaverSettings(
|
||||
val style: ScreenSaverStyle = ScreenSaverStyle.DIGITAL,
|
||||
val nightMode: Boolean = false,
|
||||
)
|
||||
@@ -155,9 +155,9 @@ object SelfCheck {
|
||||
/**
|
||||
* Only total silence (`INTERRUPTION_FILTER_NONE`) is treated as blocking
|
||||
* alarms: Priority and Alarms-only modes leave the system's own alarm
|
||||
* exemption in effect. Clockula never requests `ACCESS_NOTIFICATION_POLICY`
|
||||
* and never changes DND itself — it only reports what the user already has
|
||||
* (`docs/PLAN.md` §4).
|
||||
* exemption in effect. The check never changes DND itself — it only
|
||||
* reports what the user already has (`docs/PLAN.md` §4). The opt-in
|
||||
* bedtime mode does change it, with a rule that always allows alarms.
|
||||
*/
|
||||
private fun dnd(state: DeviceState): CheckResult {
|
||||
val blocked = state.interruptionFilter == INTERRUPTION_FILTER_NONE
|
||||
@@ -186,21 +186,24 @@ object SelfCheck {
|
||||
|
||||
/**
|
||||
* Compares Clockula's own computed next fire against what the system
|
||||
* actually has registered. No alarm scheduled is not a problem; an
|
||||
* unknown owner is given the benefit of the doubt (the platform does not
|
||||
* always expose it), but a *confirmed* foreign owner, a missing system
|
||||
* registration, or a mismatched time all mean the registration this app
|
||||
* made is not the one the system will honour.
|
||||
* actually has registered. The system reports only the earliest alarm
|
||||
* clock of *any* app, so another app's alarm at or before Clockula's hides
|
||||
* Clockula's registration rather than disproving it, and is not a problem.
|
||||
* No alarm scheduled is not a problem either. A missing registration, our
|
||||
* own at the wrong time, or anything later than our next fire all mean the
|
||||
* registration this app made is not the one the system will honour.
|
||||
*/
|
||||
private fun nextAlarm(state: DeviceState): CheckResult {
|
||||
val expected = state.expectedNextFire
|
||||
val system = state.systemNextAlarm
|
||||
val owner = state.systemNextAlarmOwnerPackage
|
||||
val ok = when {
|
||||
expected == null -> true
|
||||
state.systemNextAlarm == null -> false
|
||||
owner != null && owner != state.ownPackageName -> false
|
||||
state.systemNextAlarm != expected -> false
|
||||
else -> true
|
||||
system == null -> false
|
||||
system == expected -> true
|
||||
system > expected -> false
|
||||
// Earlier than ours: someone else's, unless the owner is confirmed as us.
|
||||
else -> owner != state.ownPackageName
|
||||
}
|
||||
return CheckResult(
|
||||
SelfCheckId.NEXT_ALARM,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.jeanlucmakiola.clockula.domain.tile
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.Timer
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
|
||||
import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetPresenter
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* What the quick-timer Quick Settings tile shows. The tile is the quick-timer
|
||||
* widget's twin (`domain/widget/QuickTimerWidgetSettings.kt`): one duration,
|
||||
* and the transient timer it last started while that one is still active.
|
||||
*
|
||||
* Nothing here ticks. A tile is redrawn only when the app asks it to be, so a
|
||||
* running timer shows when it **ends** — an absolute wall-clock time that
|
||||
* cannot go stale between the writes that refresh it — never a countdown.
|
||||
*/
|
||||
sealed interface QuickTimerTileModel {
|
||||
|
||||
/** Offering to start a timer of [duration]; a tap starts one. */
|
||||
data class AtRest(val duration: Duration) : QuickTimerTileModel
|
||||
|
||||
/**
|
||||
* Its timer [timerId] active. A tap resets it — which, for a transient
|
||||
* timer, stops it (or its ring) and deletes it, as the widget's second
|
||||
* tap does.
|
||||
*/
|
||||
sealed interface Active : QuickTimerTileModel {
|
||||
val timerId: Long
|
||||
}
|
||||
|
||||
/** Its timer counting down, due at [endsAt]. */
|
||||
data class Running(override val timerId: Long, val endsAt: Instant) : Active
|
||||
|
||||
/** Its timer paused with [remaining] left. */
|
||||
data class Paused(override val timerId: Long, val remaining: Duration) : Active
|
||||
|
||||
/** Its timer ringing. */
|
||||
data class Expired(override val timerId: Long) : Active
|
||||
}
|
||||
|
||||
object QuickTimerTilePresenter {
|
||||
|
||||
/**
|
||||
* [timerId] is the timer the tile last started; once it is deleted or back
|
||||
* to idle, the tile is at rest again. The same selection as the widget's,
|
||||
* so the two can never disagree about whether a quick timer is "theirs".
|
||||
*/
|
||||
fun present(
|
||||
duration: Duration,
|
||||
timerId: Long?,
|
||||
timers: List<Timer>,
|
||||
elapsedRealtime: Duration,
|
||||
wallClock: Instant,
|
||||
): QuickTimerTileModel {
|
||||
val active = QuickTimerWidgetPresenter.present(duration, timerId, timers, elapsedRealtime, wallClock).active
|
||||
?: return QuickTimerTileModel.AtRest(duration)
|
||||
return when (active.mode) {
|
||||
// The stored end, which the engine re-anchors on a clock change,
|
||||
// rather than one re-derived per read: two clocks read a moment
|
||||
// apart would make every emission a "change".
|
||||
TimerMode.RUNNING -> QuickTimerTileModel.Running(active.id, active.endsAtWallClock ?: (wallClock + active.remaining))
|
||||
TimerMode.PAUSED -> QuickTimerTileModel.Paused(active.id, active.remaining)
|
||||
TimerMode.EXPIRED -> QuickTimerTileModel.Expired(active.id)
|
||||
// Never active, so never reached; at rest is the honest reading.
|
||||
TimerMode.IDLE -> QuickTimerTileModel.AtRest(duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,4 +40,27 @@ interface ZoneProvider {
|
||||
*/
|
||||
interface Ticker {
|
||||
fun ticks(period: Duration): Flow<Unit>
|
||||
|
||||
/**
|
||||
* Emits once immediately, then just after every wall-clock instant that is
|
||||
* a whole multiple of [unit] ([WallBoundaries.untilNext]). For a readout of
|
||||
* whole seconds or minutes: it wakes exactly when the readout changes and
|
||||
* not otherwise, so it is both on time and as rare as it can be.
|
||||
*/
|
||||
fun alignedTicks(unit: Duration): Flow<Unit>
|
||||
}
|
||||
|
||||
/**
|
||||
* The device's clock being changed under a running readout: the time set by
|
||||
* hand or by the network, the zone, or the 12/24-hour choice, which the
|
||||
* platform announces as a time change too. Emits once per change while
|
||||
* collected. Injected, so a test hands the change over instead of making one.
|
||||
*/
|
||||
interface ClockChanges {
|
||||
fun changes(): Flow<Unit>
|
||||
}
|
||||
|
||||
/** The device's 12/24-hour choice, read afresh on every call. */
|
||||
interface HourFormat {
|
||||
fun is24Hour(): Boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.jeanlucmakiola.clockula.domain.time
|
||||
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* How long a minute-resolution readout may sleep: until the next wall-clock
|
||||
* minute begins, so it changes on the minute rather than up to a minute late,
|
||||
* and wakes once a minute rather than on a fixed sub-second cadence.
|
||||
*/
|
||||
object MinuteBoundary {
|
||||
|
||||
private const val MILLIS_PER_MINUTE = 60_000L
|
||||
|
||||
/**
|
||||
* From [now] to the start of the next minute, in `(0, 1 min]`: exactly on a
|
||||
* boundary is a whole minute away, never zero, so a caller looping on it
|
||||
* cannot spin.
|
||||
*/
|
||||
fun untilNext(now: Instant): Duration {
|
||||
val intoMinute = Math.floorMod(now.toEpochMilliseconds(), MILLIS_PER_MINUTE)
|
||||
return (MILLIS_PER_MINUTE - intoMinute).milliseconds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.jeanlucmakiola.clockula.domain.time
|
||||
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Where the next whole second (or minute) of the wall clock falls. A readout
|
||||
* that shows whole units changes exactly at these instants, so waking there
|
||||
* rather than on a free-running cadence is what keeps it on time without
|
||||
* waking more often than it changes.
|
||||
*/
|
||||
object WallBoundaries {
|
||||
|
||||
/**
|
||||
* How long from [now] until the next instant that is a whole multiple of
|
||||
* [unit] since the epoch. Always in `(0, unit]`: standing exactly on a
|
||||
* boundary waits for the *next* one, so a loop over this never spins.
|
||||
*
|
||||
* Every zone in use today is offset from UTC by whole minutes, so a
|
||||
* boundary of the epoch is a boundary of every zone's local second and
|
||||
* minute alike.
|
||||
*/
|
||||
fun untilNext(now: Instant, unit: Duration): Duration {
|
||||
val unitMillis = unit.inWholeMilliseconds
|
||||
require(unitMillis > 0) { "unit must be at least a millisecond: $unit" }
|
||||
val into = Math.floorMod(now.toEpochMilliseconds(), unitMillis)
|
||||
return (unitMillis - into).milliseconds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.jeanlucmakiola.clockula.domain.timer
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.Timer
|
||||
import de.jeanlucmakiola.clockula.domain.TimerState
|
||||
import de.jeanlucmakiola.clockula.domain.snapshotAt
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** What a deleted timer comes back as when the user taps Undo. Pure. */
|
||||
object TimerDeletion {
|
||||
|
||||
/**
|
||||
* The row an Undo restores, or null when there is nothing to restore.
|
||||
*
|
||||
* A running or paused timer comes back exactly as it was. Its anchors are
|
||||
* on the monotonic clock, so a running one has kept counting down while it
|
||||
* was gone, and one whose end passed in the meantime rings on restore, as
|
||||
* it would have done had it never been deleted.
|
||||
*
|
||||
* A timer that had already *finished* is the exception. Deleting it
|
||||
* silenced it, which is a dismissal, so it comes back reset rather than
|
||||
* ringing a second time. A transient one (M9 D10) would have been deleted
|
||||
* by that dismissal anyway, so there is nothing to undo.
|
||||
*/
|
||||
fun snapshot(timer: Timer, elapsedRealtime: Duration, wallClock: Instant): Timer? {
|
||||
if (!timer.snapshotAt(elapsedRealtime, wallClock).hasExpired) return timer
|
||||
if (timer.deleteAfterUse) return null
|
||||
return timer.copy(
|
||||
state = TimerState.IDLE,
|
||||
remaining = timer.duration,
|
||||
startedAtElapsedRealtime = null,
|
||||
endsAtElapsedRealtime = null,
|
||||
endsAtWallClock = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user